├── .ci ├── complement_package.gotpl └── scripts │ └── gotestfmt ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ └── ci.yaml ├── .gitignore ├── .golangci.yml ├── CONTRIBUTING.md ├── ENVIRONMENT.md ├── LICENSE ├── ONBOARDING.md ├── OUT-OF-REPO-TESTS.md ├── README.md ├── b ├── alice.go ├── blueprints.go ├── clean_hs.go ├── federation_one_to_one_room.go ├── federation_two_local_one_remote.go ├── hs_with_application_service.go ├── one_to_one_room.go ├── perf_many_messages.go └── perf_many_rooms.go ├── build ├── README.md └── scripts │ └── build-and-lint.sh ├── client ├── auth.go ├── client.go └── sync.go ├── cmd ├── account-snapshot │ ├── README.md │ ├── internal │ │ ├── blueprint.go │ │ ├── redact.go │ │ └── sync.go │ └── main.go ├── gendoc │ └── main.go ├── homerunner │ ├── Dockerfile │ ├── README.md │ ├── main.go │ ├── route_create.go │ ├── route_destroy.go │ ├── routes.go │ ├── setup.go │ └── test │ │ ├── package.json │ │ ├── test.mjs │ │ ├── test.sh │ │ └── yarn.lock ├── perfgraph │ ├── README.md │ └── main.go ├── perftest │ ├── README.md │ ├── main.go │ ├── snapshot.go │ └── test.go └── sytest-coverage │ └── main.go ├── config └── config.go ├── ct └── test.go ├── dockerfiles └── README.md ├── federation ├── handle.go ├── server.go ├── server_room.go └── server_test.go ├── go.mod ├── go.sum ├── helpers ├── clientopts.go └── waiter.go ├── internal ├── data │ ├── data.go │ ├── large.png │ ├── matrix-logo.svg │ └── matrix.png ├── docker │ ├── builder.go │ ├── deployer.go │ ├── deployment.go │ └── labels.go ├── instruction │ └── runner.go └── web │ └── server.go ├── match ├── http.go └── json.go ├── must └── must.go ├── runtime ├── hs.go ├── hs_conduit.go ├── hs_conduwuit.go ├── hs_dendrite.go └── hs_synapse.go ├── should └── should.go ├── sytest.ignored.list ├── sytest.list ├── test_main.go ├── test_package.go └── tests ├── csapi ├── account_change_password_pushers_test.go ├── account_change_password_test.go ├── account_data_test.go ├── account_deactivate_test.go ├── admin_test.go ├── apidoc_content_test.go ├── apidoc_device_management_test.go ├── apidoc_login_test.go ├── apidoc_logout_test.go ├── apidoc_presence_test.go ├── apidoc_profile_avatar_url_test.go ├── apidoc_profile_displayname_test.go ├── apidoc_register_test.go ├── apidoc_request_encoding_test.go ├── apidoc_room_alias_test.go ├── apidoc_room_create_test.go ├── apidoc_room_forget_test.go ├── apidoc_room_history_visibility_test.go ├── apidoc_room_members_test.go ├── apidoc_room_receipts_test.go ├── apidoc_room_state_test.go ├── apidoc_search_test.go ├── apidoc_server_capabilities_test.go ├── apidoc_version_test.go ├── device_lists_test.go ├── e2e_key_backup_test.go ├── ignored_users_test.go ├── invalid_test.go ├── keychanges_test.go ├── main_test.go ├── media_async_uploads_test.go ├── media_misc_test.go ├── membership_on_events_test.go ├── power_levels_test.go ├── push_test.go ├── room_ban_test.go ├── room_kick_test.go ├── room_leave_test.go ├── room_members_test.go ├── room_messages_test.go ├── room_profile_test.go ├── room_relations_test.go ├── room_threads_test.go ├── room_typing_test.go ├── rooms_invite_test.go ├── rooms_members_local_test.go ├── rooms_state_test.go ├── sync_archive_test.go ├── sync_filter_test.go ├── sync_test.go ├── thread_notifications_test.go ├── to_device_test.go ├── txnid_test.go ├── upload_keys_test.go ├── url_preview_test.go ├── user_directory_display_names_test.go └── user_query_keys_test.go ├── direct_messaging_test.go ├── federation_acl_test.go ├── federation_device_list_update_test.go ├── federation_event_auth_test.go ├── federation_keys_test.go ├── federation_media_content_test.go ├── federation_presence_test.go ├── federation_query_profile_test.go ├── federation_redaction_test.go ├── federation_room_alias_test.go ├── federation_room_ban_test.go ├── federation_room_event_auth_test.go ├── federation_room_get_missing_events_test.go ├── federation_room_invite_test.go ├── federation_room_join_test.go ├── federation_room_send_test.go ├── federation_room_typing_test.go ├── federation_rooms_invite_test.go ├── federation_sync_test.go ├── federation_to_device_test.go ├── federation_unreject_rejected_test.go ├── federation_upload_keys_test.go ├── knock_restricted_test.go ├── knocking_test.go ├── main_test.go ├── media_filename_test.go ├── media_nofilename_test.go ├── media_thumbnail_test.go ├── msc2836 ├── main_test.go └── msc2836_test.go ├── msc3391 ├── main_test.go └── msc3391_test.go ├── msc3757 ├── main_test.go └── owned_state_test.go ├── msc3874 ├── main_test.go └── room_messages_relation_filter_test.go ├── msc3890 ├── main_test.go └── msc3890_test.go ├── msc3902 ├── federation_room_join_partial_state_test.go └── main_test.go ├── msc3930 ├── main_test.go └── msc3930_test.go ├── msc3967 ├── main_test.go └── msc3967_test.go ├── msc4140 ├── delayed_event_test.go └── main_test.go ├── restricted_room_hierarchy_test.go ├── restricted_rooms_test.go ├── room_hierarchy_test.go ├── room_timestamp_to_event_test.go └── unknown_endpoints_test.go /.ci/complement_package.gotpl: -------------------------------------------------------------------------------- 1 | {{- /*gotype: github.com/haveyoudebuggedit/gotestfmt/parser.Package*/ -}} 2 | {{- /* 3 | This template contains the format for an individual package. GitHub actions does not currently support nested groups so 4 | we are creating a stylized header for each package. 5 | 6 | This template is based on https://github.com/haveyoudebuggedit/gotestfmt/blob/f179b0e462a9dcf7101515d87eec4e4d7e58b92a/.gotestfmt/github/package.gotpl 7 | which is under the Unlicense licence. 8 | */ -}} 9 | {{- $settings := .Settings -}} 10 | {{- if and (or (not $settings.HideSuccessfulPackages) (ne .Result "PASS")) (or (not $settings.HideEmptyPackages) (ne .Result "SKIP") (ne (len .TestCases) 0)) -}} 11 | {{- if eq .Result "PASS" -}} 12 | {{ "\033" }}[0;32m 13 | {{- else if eq .Result "SKIP" -}} 14 | {{ "\033" }}[0;33m 15 | {{- else -}} 16 | {{ "\033" }}[0;31m 17 | {{- end -}} 18 | 📦 {{ .Name }}{{- "\033" }}[0m 19 | {{- with .Coverage -}} 20 | {{- "\033" -}}[0;37m ({{ . }}% coverage){{- "\033" -}}[0m 21 | {{- end -}} 22 | {{- "\n" -}} 23 | {{- with .Reason -}} 24 | {{- " " -}}🛑 {{ . -}}{{- "\n" -}} 25 | {{- end -}} 26 | {{- with .Output -}} 27 | {{- . -}}{{- "\n" -}} 28 | {{- end -}} 29 | {{- with .TestCases -}} 30 | {{- /* Passing tests are first */ -}} 31 | {{- range . -}} 32 | {{- if eq .Result "PASS" -}} 33 | ::group::{{ "\033" }}[0;32m✅{{ " " }}{{- .Name -}} 34 | {{- "\033" -}}[0;37m ({{if $settings.ShowTestStatus}}{{.Result}}; {{end}}{{ .Duration -}} 35 | {{- with .Coverage -}} 36 | , coverage: {{ . }}% 37 | {{- end -}}) 38 | {{- "\033" -}}[0m 39 | {{- "\n" -}} 40 | 41 | {{- with .Output -}} 42 | {{- formatTestOutput . $settings -}} 43 | {{- "\n" -}} 44 | {{- end -}} 45 | 46 | ::endgroup::{{- "\n" -}} 47 | {{- end -}} 48 | {{- end -}} 49 | 50 | {{- /* Then skipped tests are second */ -}} 51 | {{- range . -}} 52 | {{- if eq .Result "SKIP" -}} 53 | ::group::{{ "\033" }}[0;33m🚧{{ " " }}{{- .Name -}} 54 | {{- "\033" -}}[0;37m ({{if $settings.ShowTestStatus}}{{.Result}}; {{end}}{{ .Duration -}} 55 | {{- with .Coverage -}} 56 | , coverage: {{ . }}% 57 | {{- end -}}) 58 | {{- "\033" -}}[0m 59 | {{- "\n" -}} 60 | 61 | {{- with .Output -}} 62 | {{- formatTestOutput . $settings -}} 63 | {{- "\n" -}} 64 | {{- end -}} 65 | 66 | ::endgroup::{{- "\n" -}} 67 | {{- end -}} 68 | {{- end -}} 69 | 70 | {{- /* and failing tests are last */ -}} 71 | {{- range . -}} 72 | {{- if and (ne .Result "PASS") (ne .Result "SKIP") -}} 73 | ::group::{{ "\033" }}[0;31m❌{{ " " }}{{- .Name -}} 74 | {{- "\033" -}}[0;37m ({{if $settings.ShowTestStatus}}{{.Result}}; {{end}}{{ .Duration -}} 75 | {{- with .Coverage -}} 76 | , coverage: {{ . }}% 77 | {{- end -}}) 78 | {{- "\033" -}}[0m 79 | {{- "\n" -}} 80 | 81 | {{- with .Output -}} 82 | {{- formatTestOutput . $settings -}} 83 | {{- "\n" -}} 84 | {{- end -}} 85 | 86 | ::endgroup::{{- "\n" -}} 87 | {{- end -}} 88 | {{- end -}} 89 | {{- end -}} 90 | {{- "\n" -}} 91 | {{- end -}} 92 | -------------------------------------------------------------------------------- /.ci/scripts/gotestfmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # wraps `gotestfmt`, hiding output from successful packages unless 4 | # all tests passed. 5 | 6 | set -o pipefail 7 | set -e 8 | 9 | # tee the test results to a log, whilst also piping them into gotestfmt, 10 | # telling it to hide successful results, so that we can clearly see 11 | # unsuccessful results. 12 | tee complement.log | gotestfmt -hide successful-packages 13 | 14 | # gotestfmt will exit non-zero if there were any failures, so if we got to this 15 | # point, we must have had a successful result. 16 | echo "All tests successful; showing all test results" 17 | 18 | # Pipe the test results back through gotestfmt, showing all results. 19 | # The log file consists of JSON lines giving the test results, interspersed 20 | # with regular stdout lines (including reports of downloaded packages). 21 | grep '^{"Time":' complement.log | gotestfmt 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.{yaml,yml}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Automatically request reviews from the synapse-core and dendrite-core teams when a pull request comes in. 2 | * @matrix-org/synapse-core @matrix-org/dendrite-core 3 | 4 | # For modifications to complement internals, also directly request review from Kegan. 5 | /build/ @matrix-org/synapse-core @matrix-org/dendrite-core @kegsay 6 | /cmd/ @matrix-org/synapse-core @matrix-org/dendrite-core @kegsay 7 | /internal/ @matrix-org/synapse-core @matrix-org/dendrite-core @kegsay 8 | /runtime/ @matrix-org/synapse-core @matrix-org/dendrite-core @kegsay 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Pull Request Checklist 2 | 3 | - [ ] Pull request includes a [sign off](https://matrix-org.github.io/synapse/latest/development/contributing_guide.html#sign-off) 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sync_snapshot.json 2 | complement 3 | .idea 4 | .bin 5 | .DS_Store 6 | # Homerunner can end up in various places 7 | /homerunner 8 | /cmd/homerunner/homerunner 9 | 10 | # For direnv users 11 | /.envrc 12 | .direnv/ 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Complement 2 | 3 | Thank you for taking the time to contribute to Matrix! 4 | 5 | This is the repository for Complement, a black box integration testing framework for Matrix homeservers. 6 | 7 | ## Sign off 8 | 9 | We ask that everybody who contributes to this project signs off their contributions, as explained below. 10 | 11 | We follow a simple 'inbound=outbound' model for contributions: the act of submitting an 'inbound' contribution means that the contributor agrees to license their contribution under the same terms as the project's overall 'outbound' license - in our case, this is Apache Software License v2 (see [LICENSE](./LICENSE)). 12 | 13 | In order to have a concrete record that your contribution is intentional and you agree to license it under the same terms as the project's license, we've adopted the same lightweight approach used by the [Linux Kernel](https://www.kernel.org/doc/html/latest/process/submitting-patches.html), [Docker](https://github.com/docker/docker/blob/master/CONTRIBUTING.md), and many other projects: the [Developer Certificate of Origin](https://developercertificate.org/) (DCO). This is a simple declaration that you wrote the contribution or otherwise have the right to contribute it to Matrix: 14 | 15 | ``` 16 | Developer Certificate of Origin 17 | Version 1.1 18 | 19 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 20 | 660 York Street, Suite 102, 21 | San Francisco, CA 94110 USA 22 | 23 | Everyone is permitted to copy and distribute verbatim copies of this 24 | license document, but changing it is not allowed. 25 | 26 | Developer's Certificate of Origin 1.1 27 | 28 | By making a contribution to this project, I certify that: 29 | 30 | (a) The contribution was created in whole or in part by me and I 31 | have the right to submit it under the open source license 32 | indicated in the file; or 33 | 34 | (b) The contribution is based upon previous work that, to the best 35 | of my knowledge, is covered under an appropriate open source 36 | license and I have the right under that license to submit that 37 | work with modifications, whether created in whole or in part 38 | by me, under the same open source license (unless I am 39 | permitted to submit under a different license), as indicated 40 | in the file; or 41 | 42 | (c) The contribution was provided directly to me by some other 43 | person who certified (a), (b) or (c) and I have not modified 44 | it. 45 | 46 | (d) I understand and agree that this project and the contribution 47 | are public and that a record of the contribution (including all 48 | personal information I submit with it, including my sign-off) is 49 | maintained indefinitely and may be redistributed consistent with 50 | this project or the open source license(s) involved. 51 | ``` 52 | 53 | If you agree to this for your contribution, then all that's needed is to include the line in your commit or pull request comment: 54 | 55 | ``` 56 | Signed-off-by: Your Name 57 | ``` 58 | 59 | Git allows you to add this signoff automatically when using the `-s` flag to `git commit`, which uses the name and email set in your `user.name` and `user.email` git configs. -------------------------------------------------------------------------------- /ENVIRONMENT.md: -------------------------------------------------------------------------------- 1 | *This file is automatically generated via ./cmd/gendoc* 2 | 3 | ## Complement Configuration 4 | Complement is configured exclusively through the use of environment variables. These variables are described below. 5 | 6 | #### `COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS` 7 | If 1, always prints the Homeserver container logs even on success. When used with COMPLEMENT_ENABLE_DIRTY_RUNS, server logs are only printed once for reused deployments, at the very end of the test suite. 8 | - Type: `bool` 9 | - Default: 0 10 | 11 | #### `COMPLEMENT_BASE_IMAGE` 12 | **Required.** The name of the Docker image to use as a base homeserver when generating blueprints. This image must conform to Complement's rules on containers, such as listening on the correct ports. 13 | - Type: `string` 14 | 15 | #### `COMPLEMENT_BASE_IMAGE_*` 16 | This allows you to override the base image used for a particular named homeserver. For example, `COMPLEMENT_BASE_IMAGE_HS1=complement-dendrite:latest` would use `complement-dendrite:latest` for the `hs1` homeserver in blueprints, but not any other homeserver (e.g `hs2`). This matching is case-insensitive. This allows Complement to test how different homeserver implementations work with each other. 17 | - Type: `map[string]string` 18 | 19 | #### `COMPLEMENT_DEBUG` 20 | If 1, prints out more verbose logging such as HTTP request/response bodies. 21 | - Type: `bool` 22 | - Default: 0 23 | 24 | #### `COMPLEMENT_ENABLE_DIRTY_RUNS` 25 | If 1, eligible tests will be provided with reusable deployments rather than a clean deployment. Eligible tests are tests run with `Deploy(t, numHomeservers)`. If enabled, COMPLEMENT_ALWAYS_PRINT_SERVER_LOGS and COMPLEMENT_POST_TEST_SCRIPT are run exactly once, at the end of all tests in the package. The post test script is run with the test name "COMPLEMENT_ENABLE_DIRTY_RUNS", and failed=false. Enabling dirty runs can greatly speed up tests, at the cost of clear server logs and the chance of tests polluting each other. Tests using `OldDeploy` and blueprints will still have a fresh image for each test. Fresh images can still be desirable e.g user directory tests need a clean homeserver else search results can be polluted, tests which can blacklist a server over federation also need isolated deployments to stop failures impacting other tests. For these reasons, there will always be a way for a test to override this setting and get a dedicated deployment. Eventually, dirty runs will become the default running mode of Complement, with an environment variable to disable this behaviour being added later, once this has stablised. 26 | - Type: `bool` 27 | - Default: 0 28 | 29 | #### `COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT` 30 | The hostname of Complement from the perspective of a Homeserver running inside a container. This can be useful for container runtimes using another hostname to access the host from a container, like Podman that uses `host.containers.internal` instead. 31 | - Type: `string` 32 | - Default: host.docker.internal 33 | 34 | #### `COMPLEMENT_HOST_MOUNTS` 35 | A list of semicolon separated host mounts to mount on every container. The structure of the mount is `host-path:container-path:[ro]` for example `/path/on/host:/path/on/container` - you can optionally specify `:ro` to mount the path as readonly. A complete example with multiple mounts would look like `/host/a:/container/a:ro;/host/b:/container/b;/host/c:/container/c` 36 | - Type: `[]HostMount` 37 | 38 | #### `COMPLEMENT_KEEP_BLUEPRINTS` 39 | A list of space separated blueprint names to not clean up after running. For example, `one_to_one_room alice` would not delete the homeserver images for the blueprints `alice` and `one_to_one_room`. This can speed up homeserver runs if you frequently run the same base image over and over again. If the base image changes, this should not be set as it means an older version of the base image will be used for the named blueprints. 40 | - Type: `[]string` 41 | 42 | #### `COMPLEMENT_POST_TEST_SCRIPT` 43 | An arbitrary script to execute after a test was executed and before the container is removed. This can be used to extract, for example, server logs or database files. The script is passed the parameters: ContainerID, TestName, TestFailed (true/false). When combined with COMPLEMENT_ENABLE_DIRTY_RUNS, the script is called exactly once at the end of the test suite, and is called with the TestName of "COMPLEMENT_ENABLE_DIRTY_RUNS" and TestFailed=false. 44 | - Type: `string` 45 | - Default: "" 46 | 47 | #### `COMPLEMENT_SHARE_ENV_PREFIX` 48 | If set, all environment variables on the host with this prefix will be shared with every homeserver, with the prefix removed. For example, if the prefix was `FOO_` then setting `FOO_BAR=baz` on the host would translate to `BAR=baz` on the container. Useful for passing through extra Homeserver configuration options without sharing all host environment variables. 49 | - Type: `string` 50 | 51 | #### `COMPLEMENT_SPAWN_HS_TIMEOUT_SECS` 52 | The number of seconds to wait for a Homeserver container to be responsive after starting the container. Responsiveness is detected by `HEALTHCHECK` being healthy *and* the `/versions` endpoint returning 200 OK. 53 | - Type: `Duration` 54 | - Default: 30 55 | -------------------------------------------------------------------------------- /OUT-OF-REPO-TESTS.md: -------------------------------------------------------------------------------- 1 | ## How to run tests out-of-repo 2 | 3 | - Make a new go project: `go mod init some.package.name`. 4 | - Get the latest version of Complement: `go get github.com/matrix-org/complement@main` 5 | - Add a tests directory and file: `mkdir ./tests; touch ./tests/something_test.go` 6 | 7 | *something_test.go* 8 | ```go 9 | package tests 10 | 11 | import ( 12 | "testing" 13 | 14 | "github.com/matrix-org/complement" 15 | "github.com/matrix-org/complement/client" 16 | "github.com/matrix-org/complement/helpers" 17 | "github.com/matrix-org/complement/match" 18 | "github.com/matrix-org/complement/must" 19 | ) 20 | 21 | func TestCannotKickNonPresentUser(t *testing.T) { 22 | deployment := complement.Deploy(t, 1) 23 | defer deployment.Destroy(t) 24 | 25 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 26 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 27 | 28 | roomID := alice.MustCreateRoom(t, map[string]interface{}{ 29 | "preset": "public_chat", 30 | }) 31 | 32 | resp := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "kick"}, 33 | client.WithJSONBody(t, map[string]interface{}{ 34 | "user_id": bob.UserID, 35 | "reason": "testing", 36 | }), 37 | ) 38 | 39 | must.MatchResponse(t, resp, match.HTTPResponse{ 40 | StatusCode: 403, 41 | }) 42 | } 43 | ``` 44 | 45 | Complement needs to be bootstrapped in when running `go test`. This is doing via a `./tests/main_test.go` file: 46 | ```go 47 | package tests 48 | 49 | import ( 50 | "testing" 51 | 52 | "github.com/matrix-org/complement" 53 | ) 54 | 55 | func TestMain(m *testing.M) { 56 | complement.TestMain(m, "some_namespace_for_these_tests") 57 | } 58 | ``` 59 | If you are only running these tests and no other Complement tests (e.g the main ones in the Complement repo) *in parallel* (i.e `go test ./complement/tests ./myrepo/tests`) then the namespace value doesn't matter. If you _are_ running other tests in parallel the namespace needs to be unique for all possible Complement test packages, otherwise you will get container conflicts. So don't call it "fed" or "csapi" as they are used by the main Complement tests. 60 | 61 | Now set up a `COMPLEMENT_BASE_IMAGE` and run `COMPLEMENT_BASE_IMAGE=homeserver:latest go test -v ./tests`: 62 | ``` 63 | 2023/10/25 14:39:51 config: &{BaseImageURI:homeserver:latest DebugLoggingEnabled:false AlwaysPrintServerLogs:false EnvVarsPropagatePrefix: SpawnHSTimeout:30s KeepBlueprints:[] HostMounts:[] BaseImageURIs:map[] PackageNamespace:oor CACertificate:0x14000cea580 CAPrivateKey:0x14000cf5300 BestEffort:false HostnameRunningComplement:host.docker.internal EnableDirtyRuns:false HSPortBindingIP:127.0.0.1 PostTestScript:} 64 | === RUN TestCannotKickNonPresentUser 65 | foo_test.go:14: Deploy times: 4.543523667s blueprints, 3.352760416s containers 66 | client.go:621: [CSAPI] POST hs1/_matrix/client/v3/register => 200 OK (16.544958ms) 67 | client.go:621: [CSAPI] POST hs1/_matrix/client/v3/register => 200 OK (13.813708ms) 68 | client.go:621: [CSAPI] POST hs1/_matrix/client/v3/createRoom => 200 OK (56.164792ms) 69 | client.go:621: [CSAPI] POST hs1/_matrix/client/v3/rooms/!ajUaasESMwLZSTpzkq:hs1/kick => 403 Forbidden (19.165125ms) 70 | --- PASS: TestCannotKickNonPresentUser (8.31s) 71 | PASS 72 | ok oor/tests 8.829s 73 | ``` 74 | 75 | 76 | NOTE: You currently cannot set up mock federation servers as that package is still internal. You can test CSAPI and deploy >1 HS though. -------------------------------------------------------------------------------- /b/alice.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintAlice is a single user homeserver 4 | var BlueprintAlice = MustValidate(Blueprint{ 5 | Name: "alice", 6 | Homeservers: []Homeserver{ 7 | { 8 | Name: "hs1", 9 | Users: []User{ 10 | { 11 | Localpart: "@alice", 12 | DisplayName: "Alice", 13 | }, 14 | }, 15 | }, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /b/clean_hs.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintCleanHS is a clean homeserver with no rooms or users 4 | var BlueprintCleanHS = MustValidate(Blueprint{ 5 | Name: "clean_hs", 6 | Homeservers: []Homeserver{ 7 | { 8 | Name: "hs1", 9 | }, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /b/federation_one_to_one_room.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintFederationOneToOneRoom contains two homeservers with 1 user in each, who are joined 4 | // to the same room. 5 | var BlueprintFederationOneToOneRoom = MustValidate(Blueprint{ 6 | Name: "federation_one_to_one_room", 7 | Homeservers: []Homeserver{ 8 | { 9 | Name: "hs1", 10 | Users: []User{ 11 | { 12 | Localpart: "@alice", 13 | DisplayName: "Alice", 14 | }, 15 | }, 16 | Rooms: []Room{ 17 | { 18 | CreateRoom: map[string]interface{}{ 19 | "preset": "public_chat", 20 | }, 21 | Creator: "@alice", 22 | Ref: "alice_room", 23 | Events: []Event{ 24 | { 25 | Type: "m.room.message", 26 | Content: map[string]interface{}{ 27 | "body": "Hello world", 28 | "msgtype": "m.text", 29 | }, 30 | Sender: "@alice", 31 | }, 32 | }, 33 | }, 34 | }, 35 | }, 36 | { 37 | Name: "hs2", 38 | Users: []User{ 39 | { 40 | Localpart: "@bob", 41 | DisplayName: "Bob", 42 | }, 43 | }, 44 | Rooms: []Room{ 45 | { 46 | Ref: "alice_room", 47 | Events: []Event{ 48 | { 49 | Type: "m.room.member", 50 | StateKey: Ptr("@bob:hs2"), 51 | Content: map[string]interface{}{ 52 | "membership": "join", 53 | }, 54 | Sender: "@bob", 55 | }, 56 | { 57 | Type: "m.room.message", 58 | Content: map[string]interface{}{ 59 | "body": "Hello world2", 60 | "msgtype": "m.text", 61 | }, 62 | Sender: "@bob", 63 | }, 64 | }, 65 | }, 66 | }, 67 | }, 68 | }, 69 | }) 70 | -------------------------------------------------------------------------------- /b/federation_two_local_one_remote.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintFederationTwoLocalOneRemote is a two-user homeserver federating with a one-user homeserver 4 | var BlueprintFederationTwoLocalOneRemote = MustValidate(Blueprint{ 5 | Name: "federation_two_local_one_remote", 6 | Homeservers: []Homeserver{ 7 | { 8 | Name: "hs1", 9 | Users: []User{ 10 | { 11 | Localpart: "@alice", 12 | DisplayName: "Alice", 13 | }, 14 | { 15 | Localpart: "@bob", 16 | DisplayName: "Bob", 17 | }, 18 | }, 19 | }, 20 | { 21 | Name: "hs2", 22 | Users: []User{ 23 | { 24 | Localpart: "@charlie", 25 | DisplayName: "Charlie", 26 | }, 27 | }, 28 | }, 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /b/hs_with_application_service.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintHSWithApplicationService who has an application service to interact with 4 | var BlueprintHSWithApplicationService = MustValidate(Blueprint{ 5 | Name: "hs_with_application_service", 6 | Homeservers: []Homeserver{ 7 | { 8 | Name: "hs1", 9 | Users: []User{ 10 | { 11 | Localpart: "@alice", 12 | DisplayName: "Alice", 13 | }, 14 | { 15 | Localpart: "@bob", 16 | DisplayName: "Bob", 17 | }, 18 | }, 19 | ApplicationServices: []ApplicationService{ 20 | { 21 | ID: "my_as_id", 22 | URL: "http://localhost:9000", 23 | SenderLocalpart: "the-bridge-user", 24 | RateLimited: false, 25 | }, 26 | }, 27 | }, 28 | { 29 | Name: "hs2", 30 | Users: []User{ 31 | { 32 | Localpart: "@charlie", 33 | DisplayName: "Charlie", 34 | }, 35 | }, 36 | }, 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /b/one_to_one_room.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | // BlueprintOneToOneRoom contains a homeserver with 2 users, who are joined to the same room. 4 | var BlueprintOneToOneRoom = MustValidate(Blueprint{ 5 | Name: "one_to_one_room", 6 | Homeservers: []Homeserver{ 7 | { 8 | Name: "hs1", 9 | Users: []User{ 10 | { 11 | Localpart: "@alice", 12 | DisplayName: "Alice", 13 | }, 14 | { 15 | Localpart: "@bob", 16 | DisplayName: "Bob", 17 | }, 18 | }, 19 | Rooms: []Room{ 20 | { 21 | CreateRoom: map[string]interface{}{ 22 | "preset": "public_chat", 23 | }, 24 | Creator: "@alice", 25 | Events: []Event{ 26 | { 27 | Type: "m.room.member", 28 | StateKey: Ptr("@bob:hs1"), 29 | Content: map[string]interface{}{ 30 | "membership": "join", 31 | }, 32 | Sender: "@bob", 33 | }, 34 | { 35 | Type: "m.room.message", 36 | Content: map[string]interface{}{ 37 | "body": "Hello world", 38 | "msgtype": "m.text", 39 | }, 40 | Sender: "@bob", 41 | }, 42 | }, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /b/perf_many_messages.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Matrix.org Foundation C.I.C. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package b 16 | 17 | // BlueprintPerfManyMessages contains a homeserver with 2 users, who are joined to the same room with thousands of messages. 18 | var BlueprintPerfManyMessages = MustValidate(Blueprint{ 19 | Name: "perf_many_messages", 20 | Homeservers: []Homeserver{ 21 | { 22 | Name: "hs1", 23 | Users: []User{ 24 | { 25 | Localpart: "@alice", 26 | DisplayName: "Alice", 27 | }, 28 | { 29 | Localpart: "@bob", 30 | DisplayName: "Bob", 31 | }, 32 | }, 33 | Rooms: []Room{ 34 | { 35 | CreateRoom: map[string]interface{}{ 36 | "preset": "public_chat", 37 | }, 38 | Creator: "@alice", 39 | Events: append([]Event{ 40 | Event{ 41 | Type: "m.room.member", 42 | StateKey: Ptr("@bob:hs1"), 43 | Content: map[string]interface{}{ 44 | "membership": "join", 45 | }, 46 | Sender: "@bob", 47 | }, 48 | }, manyMessages([]string{"@alice", "@bob"}, 7000)...), 49 | }, 50 | }, 51 | }, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /b/perf_many_rooms.go: -------------------------------------------------------------------------------- 1 | // Copyright 2020 The Matrix.org Foundation C.I.C. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package b 16 | 17 | // BlueprintPerfManyRooms contains a homeserver with 2 users, who are joined to hundreds of rooms with a few messages in each. 18 | var BlueprintPerfManyRooms = MustValidate(Blueprint{ 19 | Name: "perf_many_rooms", 20 | Homeservers: []Homeserver{ 21 | { 22 | Name: "hs1", 23 | Users: []User{ 24 | { 25 | Localpart: "@alice", 26 | DisplayName: "Alice", 27 | }, 28 | { 29 | Localpart: "@bob", 30 | DisplayName: "Bob", 31 | }, 32 | }, 33 | Rooms: perfManyRooms([]string{"@alice", "@bob"}, 400), 34 | }, 35 | }, 36 | }) 37 | 38 | func perfManyRooms(users []string, count int) []Room { 39 | rooms := make([]Room, count) 40 | for i := 0; i < count; i++ { 41 | creator := users[i%len(users)] 42 | var events []Event 43 | for j := 0; j < len(users); j++ { 44 | if users[j] == creator { 45 | continue 46 | } 47 | // join the room 48 | events = append(events, Event{ 49 | Type: "m.room.member", 50 | StateKey: Ptr(users[j]), 51 | Content: map[string]interface{}{ 52 | "membership": "join", 53 | }, 54 | Sender: users[j], 55 | }) 56 | } 57 | 58 | events = append(events, manyMessages(users, 20)...) 59 | 60 | room := Room{ 61 | CreateRoom: map[string]interface{}{ 62 | "preset": "public_chat", 63 | }, 64 | Creator: creator, 65 | Events: events, 66 | } 67 | rooms[i] = room 68 | } 69 | return rooms 70 | } 71 | -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | # Dev Scripts 2 | 3 | These are a collection of scripts that should be helpful for those developing 4 | on Complement. 5 | 6 | See `find-lint.sh` for environment variables that control linter resource 7 | usage. 8 | 9 | -------------------------------------------------------------------------------- /build/scripts/build-and-lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | # Runs the linters against complement 4 | 5 | # The linters can take a lot of resources and are slow, so they can be 6 | # configured using the following environment variables: 7 | # 8 | # - `COMPLEMENT_LINT_CONCURRENCY` - number of concurrent linters to run, 9 | # golangci-lint defaults this to NumCPU 10 | # - `GOGC` - how often to perform garbage collection during golangci-lint runs. 11 | # Essentially a ratio of memory/speed. See https://golangci-lint.run/usage/performance/#memory-usage 12 | # for more info. 13 | 14 | 15 | cd `dirname $0`/../.. 16 | 17 | args="" 18 | if [ ${1:-""} = "fast" ] 19 | then args="--fast" 20 | fi 21 | 22 | if [ -z ${COMPLEMENT_LINT_CONCURRENCY+x} ]; then 23 | # COMPLEMENT_LINT_CONCURRENCY was not set 24 | : 25 | else 26 | args="${args} --concurrency $COMPLEMENT_LINT_CONCURRENCY" 27 | fi 28 | 29 | echo "Installing golangci-lint..." 30 | 31 | # Make a backup of go.{mod,sum} first 32 | cp go.mod go.mod.bak && cp go.sum go.sum.bak 33 | go get github.com/golangci/golangci-lint/cmd/golangci-lint@v1.33.0 34 | echo "" 35 | 36 | # Build the code 37 | # This shouldn't be required, but can help eliminate errors. 38 | # See https://github.com/golangci/golangci-lint/issues/825 for an error that can occur if 39 | # the code isn't build first. 40 | echo "Building complement..." 41 | go build -tags="*" ./... 42 | 43 | # Capture exit code to ensure go.{mod,sum} is restored before exiting 44 | exit_code=0 45 | 46 | # Run linting 47 | echo "Looking for lint..." 48 | (golangci-lint run $args ./internal/... ./tests/... && echo "No issues found :)") || exit_code=1 49 | 50 | # Restore go.{mod,sum} 51 | mv go.mod.bak go.mod && mv go.sum.bak go.sum 52 | 53 | exit $exit_code 54 | -------------------------------------------------------------------------------- /cmd/account-snapshot/README.md: -------------------------------------------------------------------------------- 1 | ### Account Snapshot 2 | 3 | This is capable of taking an anonymised snapshot of a Matrix account and then create a Homerunner in-line blueprint for it. It has several stages: 4 | - Perform a full `/sync` request. This is cached in `sync_snapshot.json` for subsequent runs in case something fails. 5 | - Redact the `/sync` response. This removes all PII including user IDs, messages, room IDs, event IDs, attachments, avatars, display names, etc. 6 | An intermediate `Snapshot` struct is the returned. 7 | A whitelist approach is used, where only specific event types are persisted. This guarantees that non-spec state events which could be revealing 8 | are ignored. However, we apply a blacklist approach for spec-defined event types. This means it is possible that non-spec fields may leak through 9 | into the subsequent stages. A reasonably easy way to check to see how well the redaction process worked is to: 10 | ``` 11 | grep -Ei "the|matrix|and|mxc" blueprint.json | sort | uniq 12 | ``` 13 | As this will flag `mxc://` URIs as well as hopefully any textual messages. 14 | - Map the `Snapshot` to a `Blueprint` which is capable to be run using `./cmd/homerunner`. The `Blueprint` consists of all the users in the `/sync` 15 | response, all joined rooms, and the most recent 50 messages. 16 | 17 | 18 | To try it out: (this will take between 5-60 mins depending on how large your account is) 19 | ``` 20 | ./account-snapshot -user @alice:matrix.org -token MDA.... > blueprint.json 21 | ``` 22 | Then run Homerunner in single-shot mode: (this will take hours or days depending on the homeserver and how many events there are) 23 | ``` 24 | HOMERUNNER_SNAPSHOT_BLUEPRINT=blueprint.json ./homerunner 25 | ``` 26 | This will execute the blueprint and commit the resulting images so you can push them to docker hub/gitlab. IMPORTANT: Make sure to set `HOMERUNNER_KEEP_BLUEPRINTS=your-blueprint-name` when running homerunner subsequently or **it will clean up the image**. 27 | 28 | #### Limitations 29 | 30 | Currently, the `/sync` -> snapshot does not: 31 | - Handle push rules in account data 32 | - Handle invites 33 | - Handle third party invites 34 | 35 | Currently, the snapshot -> blueprint does not: 36 | - Handle `m.reaction` or `m.room.redaction` events - the `/sync` response may not include the events being redacted, so we need to guess/make-up an appropriate 37 | event to react to or redact. 38 | - Handle `m.room.tombstone` events. This is more a Complement limitation. 39 | - Handle `m.room.encrypted` events. Whilst Complement does upload OTKs, it needs to have a field to mark "send this event as E2E" in `b.Event`. 40 | -------------------------------------------------------------------------------- /cmd/account-snapshot/internal/sync.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "strconv" 14 | "strings" 15 | ) 16 | 17 | // LoadSyncData loads sync data from disk or by doing a /sync request 18 | func LoadSyncData(hsURL, token, tempFile string) (json.RawMessage, error) { 19 | syncData := loadDataFromDisk(tempFile) 20 | if syncData != nil { 21 | log.Printf("Loaded sync data from %s\n", tempFile) 22 | return syncData, nil 23 | } 24 | // We need to do a remote hit to the homeserver 25 | httpCli := &http.Client{} 26 | 27 | filterStr := url.QueryEscape(`{"event_format":"federation", "room":{"timeline":{"limit":50}}}`) 28 | 29 | attempts := 0 30 | 31 | var body []byte 32 | for attempts < 20 { 33 | attempts++ 34 | // Perform the sync 35 | log.Println("Performing /sync...") 36 | filterReq, err := http.NewRequest("GET", hsURL+"/_matrix/client/v3/sync?filter="+filterStr, nil) 37 | if err != nil { 38 | log.Printf("failed to create sync request: %s\n", err) 39 | continue 40 | } 41 | body, err = doRequest(httpCli, filterReq, token) 42 | if err != nil { 43 | log.Printf("failed to perform sync request: %s\n", err) 44 | continue 45 | } 46 | break 47 | } 48 | if body == nil { 49 | return nil, fmt.Errorf("failed to perform /sync") 50 | } 51 | 52 | // dump it straight to disk first 53 | err := ioutil.WriteFile(tempFile, body, 0644) 54 | if err != nil { 55 | log.Printf("WARNING: failed to write sync data to disk: %s", err) 56 | } 57 | 58 | return body, nil 59 | } 60 | 61 | func loadDataFromDisk(tempFile string) json.RawMessage { 62 | data, err := ioutil.ReadFile(tempFile) 63 | if err != nil { 64 | if os.IsNotExist(err) { 65 | return nil 66 | } 67 | log.Panicf("FATAL: failed to read data from disk: %s", err) 68 | } 69 | return data 70 | } 71 | 72 | func doRequest(httpCli *http.Client, req *http.Request, token string) ([]byte, error) { 73 | req.Header.Set("Authorization", "Bearer "+token) 74 | res, err := httpCli.Do(req) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to perform request: %w", err) 77 | } 78 | defer res.Body.Close() 79 | if res.StatusCode != 200 { 80 | return nil, fmt.Errorf("response returned %s", res.Status) 81 | } 82 | out := bytes.NewBuffer(nil) 83 | // non-fatal if we have no content length 84 | cl, _ := strconv.Atoi(res.Header.Get("Content-Length")) 85 | counter := &writeCounter{ 86 | contentLength: cl, 87 | } 88 | if _, err = io.Copy(out, io.TeeReader(res.Body, counter)); err != nil { 89 | return nil, err 90 | } 91 | return out.Bytes(), nil 92 | } 93 | 94 | type writeCounter struct { 95 | contentLength int 96 | total int 97 | } 98 | 99 | func (wc *writeCounter) Write(p []byte) (int, error) { 100 | n := len(p) 101 | wc.total += n 102 | // wipe current line 103 | fmt.Fprintf(os.Stderr, "\r%s", strings.Repeat(" ", 80)) 104 | fmt.Fprintf(os.Stderr, "\rDownloading... %d / %d KB complete", wc.total/1024, wc.contentLength/1024) 105 | return n, nil 106 | } 107 | -------------------------------------------------------------------------------- /cmd/account-snapshot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | 10 | "github.com/matrix-org/complement/b" 11 | "github.com/matrix-org/complement/cmd/account-snapshot/internal" 12 | ) 13 | 14 | /* 15 | * Account Snapshot - Take an anonymised snapshot of this account. 16 | * Raw /sync results stored in sync_snapshot.json 17 | * The anonymised output is written to stdout 18 | */ 19 | 20 | var ( 21 | flagAccessToken = flag.String("token", "", "Account access token") 22 | flagHSURL = flag.String("url", "https://matrix.org", "HS URL") 23 | flagUserID = flag.String("user", "", "Matrix User ID, needed to configure blueprints correctly for account data") 24 | flagFromAnon = flag.String("from-anon", "", "If set, loads anonymous snapshot from file and then produces blueprint") 25 | flagAnonOnly = flag.Bool("anon-only", false, "If set, outputs an anonymous sync output only, not a blueprint") 26 | imageURI = "complement-dendrite:latest" 27 | ) 28 | 29 | func main() { 30 | flag.Parse() 31 | var snapshot *internal.Snapshot 32 | if *flagFromAnon != "" { 33 | f, err := os.Open(*flagFromAnon) 34 | if err != nil { 35 | log.Panicf("FATAL: Failed to open anonymous snapshot: %s", err) 36 | } 37 | if err := json.NewDecoder(f).Decode(&snapshot); err != nil { 38 | log.Panicf("FATAL: Failed to read anonymous snapshot as JSON: %s", err) 39 | } 40 | } else { 41 | if *flagAccessToken == "" || *flagUserID == "" { 42 | var eventsHandled string 43 | for evType := range internal.RedactRules { 44 | eventsHandled += " " + evType + "\n" 45 | } 46 | fmt.Fprintf(os.Stderr, 47 | "Capture an anonymous snapshot of this account.\n"+ 48 | "User name is required to map DM rooms correctly.\n"+ 49 | "/sync output is stored in 'sync_snapshot.json'\n"+ 50 | "Anonymised output is written to stdout\n\n"+ 51 | "Usage: ./account_snapshot -token MDA.... -user @alice:matrix.org > output.json\n\n"+ 52 | "Currently handles the following events:\n"+eventsHandled+"\n\n") 53 | flag.PrintDefaults() 54 | os.Exit(1) 55 | } 56 | 57 | syncData, err := internal.LoadSyncData(*flagHSURL, *flagAccessToken, "sync_snapshot.json") 58 | if err != nil { 59 | log.Panicf("FATAL: LoadSyncData %s\n", err) 60 | } 61 | var anonMappings internal.AnonMappings 62 | anonMappings.Devices = make(map[string]string) 63 | anonMappings.Servers = make(map[string]string) 64 | anonMappings.Users = make(map[string]string) 65 | anonMappings.Rooms = make(map[string]string) 66 | anonMappings.AnonUserToDevices = make(map[string]map[string]bool) 67 | anonMappings.SingleServerName = "hs1" 68 | snapshot = internal.Redact(syncData, anonMappings) 69 | snapshot.UserID = anonMappings.User(*flagUserID) 70 | if *flagAnonOnly { 71 | b, err := json.MarshalIndent(snapshot, "", " ") 72 | if err != nil { 73 | log.Printf("WARNING: failed to marshal anonymous snapshot: %s", err) 74 | } else { 75 | fmt.Printf(string(b) + "\n") 76 | } 77 | os.Exit(0) 78 | } 79 | } 80 | bp, err := internal.ConvertToBlueprint(snapshot, "hs1") 81 | if err != nil { 82 | log.Panicf("FATAL: ConvertToBlueprint %s\n", err) 83 | } 84 | 85 | homerunnerReq := struct { 86 | Blueprint *b.Blueprint `json:"blueprint"` 87 | BaseImageURI string `json:"base_image_uri"` 88 | }{ 89 | Blueprint: bp, 90 | BaseImageURI: imageURI, 91 | } 92 | 93 | b, err := json.MarshalIndent(homerunnerReq, "", " ") 94 | if err != nil { 95 | log.Printf("WARNING: failed to marshal blueprint: %s", err) 96 | } else { 97 | fmt.Printf(string(b) + "\n") 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /cmd/gendoc/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "go/token" 9 | "log" 10 | "os" 11 | "sort" 12 | "strings" 13 | ) 14 | 15 | var configPath = flag.String("config", "internal/config/config.go", "The path to internal/config/config.go") 16 | 17 | type VarDoc struct { 18 | Name string 19 | Description string 20 | Default string 21 | Type string 22 | } 23 | 24 | func NewVarDoc(docstring string) (vd VarDoc) { 25 | lines := strings.Split(docstring, "\n") 26 | isDescription := false 27 | for _, l := range lines { 28 | if strings.HasPrefix(l, "Name:") { 29 | isDescription = false 30 | vd.Name = strings.TrimSpace(strings.TrimPrefix(l, "Name:")) 31 | } 32 | if strings.HasPrefix(l, "Default:") { 33 | isDescription = false 34 | vd.Default = strings.TrimSpace(strings.TrimPrefix(l, "Default:")) 35 | } 36 | if strings.HasPrefix(l, "Description:") { 37 | l = strings.TrimPrefix(l, "Description:") 38 | isDescription = true 39 | } 40 | if isDescription { 41 | vd.Description += strings.TrimSpace(l) + " " 42 | } 43 | } 44 | return 45 | } 46 | 47 | func findComplementStruct(path string) *ast.StructType { 48 | fset := token.NewFileSet() 49 | node, err := parser.ParseFile(fset, path, nil, parser.ParseComments) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | var complementConfigType *ast.TypeSpec 54 | FindStruct: 55 | for _, d := range node.Decls { 56 | typeNode, ok := d.(*ast.GenDecl) 57 | if !ok || typeNode.Tok != token.TYPE { // we want `type` keywords 58 | continue 59 | } 60 | for _, s := range typeNode.Specs { 61 | typeSpec, ok := s.(*ast.TypeSpec) 62 | if !ok { 63 | continue 64 | } 65 | if typeSpec.Name.Name == "Complement" { 66 | complementConfigType = typeSpec 67 | break FindStruct 68 | } 69 | } 70 | } 71 | sType, ok := complementConfigType.Type.(*ast.StructType) 72 | if !ok { 73 | return nil 74 | } 75 | return sType 76 | } 77 | 78 | func typeForExpr(ex ast.Expr) string { 79 | switch typeDecl := ex.(type) { 80 | case *ast.Ident: 81 | return typeDecl.Name 82 | case *ast.SelectorExpr: 83 | return typeDecl.Sel.Name 84 | case *ast.ArrayType: 85 | return "[]" + typeForExpr(typeDecl.Elt) 86 | case *ast.MapType: 87 | return "map[" + typeForExpr(typeDecl.Key) + "]" + typeForExpr(typeDecl.Value) 88 | default: 89 | return "-" 90 | } 91 | } 92 | 93 | func main() { 94 | flag.Parse() 95 | if *configPath == "" { 96 | flag.Usage() 97 | os.Exit(1) 98 | } 99 | complement := findComplementStruct(*configPath) 100 | if complement == nil { 101 | log.Fatal("file does not contain type Complement struct {...}") 102 | } 103 | var varDocs []VarDoc 104 | // loop each field looking for valid comments 105 | for _, f := range complement.Fields.List { 106 | fieldComment := f.Doc.Text() 107 | vd := NewVarDoc(fieldComment) 108 | if vd.Name == "" { 109 | continue // not valid comment 110 | } 111 | vd.Type = typeForExpr(f.Type) 112 | varDocs = append(varDocs, vd) 113 | } 114 | sort.Slice(varDocs, func(i, j int) bool { 115 | return varDocs[i].Name < varDocs[j].Name 116 | }) 117 | mdFileLines := []string{ 118 | "*This file is automatically generated via ./cmd/gendoc*", 119 | "", 120 | "## Complement Configuration", 121 | "Complement is configured exclusively through the use of environment variables. These variables are described below.", 122 | } 123 | for _, vd := range varDocs { 124 | mdFileLines = append(mdFileLines, fmt.Sprintf("\n#### `%v`", vd.Name)) 125 | mdFileLines = append(mdFileLines, vd.Description) 126 | mdFileLines = append(mdFileLines, fmt.Sprintf("- Type: `%v`", vd.Type)) 127 | if vd.Default != "" { 128 | mdFileLines = append(mdFileLines, fmt.Sprintf("- Default: %v", vd.Default)) 129 | } 130 | } 131 | fmt.Println(strings.Join(mdFileLines, "\n")) 132 | } 133 | -------------------------------------------------------------------------------- /cmd/homerunner/Dockerfile: -------------------------------------------------------------------------------- 1 | # This dockerfile simply holds `homerunner` when run in a containerized env. 2 | # see matrix-org/complement/dockerfiles for links to the complement 3 | # images that are used in tests. 4 | 5 | # NB: building needs to be done from the root of the complement directory, ie 6 | # `~/work/complement> docker build -t cmd/homerunner/Dockerfile . ` 7 | # to ensure the whole project is passed to the docker container to build. 8 | 9 | # Build 10 | 11 | FROM golang:1.19-buster 12 | RUN echo "deb http://deb.debian.org/debian buster-backports main" > /etc/apt/sources.list.d/complement.list 13 | RUN apt-get update && apt-get install -y libolm3 libolm-dev/buster-backports 14 | 15 | WORKDIR /app 16 | COPY . /app 17 | 18 | RUN go build ./cmd/homerunner 19 | 20 | # Executable 21 | 22 | FROM debian:buster 23 | RUN echo "deb http://deb.debian.org/debian buster-backports main" > /etc/apt/sources.list.d/complement.list 24 | RUN apt-get update && apt-get install -y libolm3 && apt-get clean 25 | 26 | COPY --from=0 /app/homerunner /usr/local/bin/homerunner 27 | 28 | HEALTHCHECK --interval=1m --timeout=5s \ 29 | CMD curl -f http://localhost:54321/ || exit 1 30 | 31 | 32 | EXPOSE 54321/tcp 33 | ENTRYPOINT ["/usr/local/bin/homerunner"] 34 | 35 | -------------------------------------------------------------------------------- /cmd/homerunner/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/matrix-org/complement/config" 13 | "github.com/matrix-org/complement/internal/docker" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | const Pkg = "homerunner" 18 | 19 | type Config struct { 20 | HomeserverLifetimeMins int 21 | Port int 22 | SpawnHSTimeout time.Duration 23 | KeepBlueprints []string 24 | Snapshot string 25 | HSPortBindingIP string 26 | } 27 | 28 | func (c *Config) DeriveComplementConfig(baseImageURI string) *config.Complement { 29 | cfg := config.NewConfigFromEnvVars(Pkg, baseImageURI) 30 | cfg.BestEffort = true 31 | cfg.KeepBlueprints = c.KeepBlueprints 32 | cfg.SpawnHSTimeout = c.SpawnHSTimeout 33 | cfg.HSPortBindingIP = c.HSPortBindingIP 34 | return cfg 35 | } 36 | 37 | func Getenv(key string, default_value string) string { 38 | value, exists := os.LookupEnv(key) 39 | if exists { 40 | return value 41 | } else { 42 | return default_value 43 | } 44 | } 45 | 46 | func NewConfig() *Config { 47 | cfg := &Config{ 48 | HomeserverLifetimeMins: 30, 49 | Port: 54321, 50 | SpawnHSTimeout: 5 * time.Second, 51 | KeepBlueprints: strings.Split(os.Getenv("HOMERUNNER_KEEP_BLUEPRINTS"), " "), 52 | Snapshot: os.Getenv("HOMERUNNER_SNAPSHOT_BLUEPRINT"), 53 | HSPortBindingIP: Getenv("HOMERUNNER_HS_PORTBINDING_IP", "127.0.0.1"), 54 | } 55 | if val, _ := strconv.Atoi(os.Getenv("HOMERUNNER_LIFETIME_MINS")); val != 0 { 56 | cfg.HomeserverLifetimeMins = val 57 | } 58 | if val, _ := strconv.Atoi(os.Getenv("HOMERUNNER_PORT")); val != 0 { 59 | cfg.Port = val 60 | } 61 | if val, _ := strconv.Atoi(os.Getenv("HOMERUNNER_SPAWN_HS_TIMEOUT_SECS")); val != 0 { 62 | cfg.SpawnHSTimeout = time.Duration(val) * time.Second 63 | } 64 | return cfg 65 | } 66 | 67 | func cleanup(c *Config) { 68 | cfg := c.DeriveComplementConfig("nothing") 69 | builder, err := docker.NewBuilder(cfg) 70 | if err != nil { 71 | logrus.WithError(err).Fatalf("failed to run cleanup") 72 | } 73 | builder.Cleanup() 74 | } 75 | 76 | func main() { 77 | cfg := NewConfig() 78 | rt, err := NewRuntime(cfg) 79 | if err != nil { 80 | logrus.Fatalf("failed to setup new runtime: %s", err) 81 | } 82 | cleanup(cfg) 83 | 84 | if cfg.Snapshot != "" { 85 | logrus.Infof("Running in single-shot snapshot mode for request file '%s'", cfg.Snapshot) 86 | // pretend the file is the request 87 | reqFile, err := os.Open(cfg.Snapshot) 88 | if err != nil { 89 | logrus.Fatalf("failed to read snapshot: %s", err) 90 | } 91 | var rc ReqCreate 92 | if err := json.NewDecoder(reqFile).Decode(&rc); err != nil { 93 | logrus.Fatalf("file is not JSON: %s", err) 94 | } 95 | dep, _, err := rt.CreateDeployment(rc.BaseImageURI, rc.Blueprint) 96 | if err != nil { 97 | logrus.Fatalf("failed to create deployment: %s", err) 98 | } 99 | logrus.Infof("Successful deployment. Created %d homeserver images visible in 'docker image ls'.", len(dep.HS)) 100 | logrus.Infof("Clients: to run this blueprint in homerunner, use the blueprint name '%s'", rc.Blueprint.Name) 101 | logrus.Infof("Servers: Run Homerunner with the env var HOMERUNNER_KEEP_BLUEPRINTS=%s set to prevent this blueprint being cleaned up", rc.Blueprint.Name) 102 | 103 | // clean up after ourselves 104 | _ = rt.DestroyDeployment(dep.BlueprintName) 105 | return 106 | } 107 | 108 | srv := &http.Server{ 109 | ReadTimeout: 10 * time.Minute, 110 | WriteTimeout: 10 * time.Minute, 111 | Handler: Routes(rt, cfg), 112 | Addr: fmt.Sprintf("0.0.0.0:%d", cfg.Port), 113 | } 114 | logrus.Infof("Homerunner listening on :%d with config %+v", cfg.Port, cfg) 115 | 116 | if err := srv.ListenAndServe(); err != nil { 117 | logrus.Fatalf("ListenAndServe failed: %s", err) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /cmd/homerunner/route_create.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/matrix-org/complement/b" 9 | "github.com/matrix-org/complement/internal/docker" 10 | "github.com/matrix-org/util" 11 | ) 12 | 13 | type ReqCreate struct { 14 | BaseImageURI string `json:"base_image_uri"` 15 | BlueprintName string `json:"blueprint_name"` 16 | Blueprint *b.Blueprint `json:"blueprint"` 17 | } 18 | 19 | type ResCreate struct { 20 | Homeservers map[string]*docker.HomeserverDeployment `json:"homeservers"` 21 | Expires time.Time `json:"expires"` 22 | } 23 | 24 | // RouteCreate handles creating blueprint deployments. There are 3 supported types of requests: 25 | // - A: Creating a blueprint from the static ones in `internal/b` : This is what Complement does. 26 | // - B: Creating an in-line blueprint where the blueprint is in the request. 27 | // - C: Creating a deployment from a pre-made blueprint image, e.g using account-snapshot. 28 | func RouteCreate(ctx context.Context, rt *Runtime, rc *ReqCreate) util.JSONResponse { 29 | // Use case A: if the blueprint name is given, check for static ones 30 | knownBlueprint, ok := b.KnownBlueprints[rc.BlueprintName] 31 | if ok { 32 | // clobber it and pretend it's inline 33 | rc.Blueprint = knownBlueprint 34 | } 35 | 36 | if rc.Blueprint != nil { 37 | // Use cases A,B - we're making blueprints from scratch, meaning we need a base image 38 | if rc.BaseImageURI == "" { 39 | return util.MessageResponse(400, "missing base image uri") 40 | } 41 | } else if rc.BlueprintName != "" { 42 | // Use case C: the blueprint name isn't static, try it with just a name which will succeed 43 | // if the blueprint is already in the docker image cache. 44 | rc.Blueprint = &b.Blueprint{ 45 | Name: rc.BlueprintName, 46 | } 47 | rc.BaseImageURI = "none" 48 | } else { 49 | return util.MessageResponse(400, "one of 'blueprint_name' or 'blueprint' must be specified") 50 | } 51 | 52 | dep, expires, err := rt.CreateDeployment(rc.BaseImageURI, rc.Blueprint) 53 | if err != nil { 54 | return util.MessageResponse(400, fmt.Sprintf("failed to create deployment: %s", err)) 55 | } 56 | return util.JSONResponse{ 57 | Code: 200, 58 | JSON: ResCreate{ 59 | Homeservers: dep.HS, 60 | Expires: expires, 61 | }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/homerunner/route_destroy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/matrix-org/util" 8 | ) 9 | 10 | type ReqDestroy struct { 11 | BlueprintName string `json:"blueprint_name"` 12 | } 13 | 14 | type ResDestroy struct { 15 | } 16 | 17 | func RouteDestroy(ctx context.Context, rt *Runtime, rc *ReqDestroy) util.JSONResponse { 18 | if rc.BlueprintName == "" { 19 | return util.MessageResponse(400, "missing blueprint name") 20 | } 21 | err := rt.DestroyDeployment(rc.BlueprintName) 22 | if err != nil { 23 | return util.MessageResponse(500, fmt.Sprintf("failed to destroy deployment: %s", err)) 24 | } 25 | return util.JSONResponse{ 26 | Code: 200, 27 | JSON: ResDestroy{}, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cmd/homerunner/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/matrix-org/util" 9 | ) 10 | 11 | func Routes(rt *Runtime, cfg *Config) http.Handler { 12 | mux := mux.NewRouter() 13 | mux.Path("/create").Methods("POST").HandlerFunc( 14 | util.WithCORSOptions(util.MakeJSONAPI(util.NewJSONRequestHandler( 15 | func(req *http.Request) util.JSONResponse { 16 | rc := ReqCreate{} 17 | if err := json.NewDecoder(req.Body).Decode(&rc); err != nil { 18 | return util.MessageResponse(400, "request body not JSON") 19 | } 20 | return RouteCreate(req.Context(), rt, &rc) 21 | }, 22 | ))), 23 | ) 24 | mux.Path("/destroy").Methods("POST").HandlerFunc( 25 | util.WithCORSOptions(util.MakeJSONAPI(util.NewJSONRequestHandler( 26 | func(req *http.Request) util.JSONResponse { 27 | rc := ReqDestroy{} 28 | if err := json.NewDecoder(req.Body).Decode(&rc); err != nil { 29 | return util.MessageResponse(400, "request body not JSON") 30 | } 31 | return RouteDestroy(req.Context(), rt, &rc) 32 | }, 33 | ))), 34 | ) 35 | mux.Path("/health").Methods("GET").HandlerFunc( 36 | func(res http.ResponseWriter, req *http.Request) { 37 | res.WriteHeader(200) 38 | }, 39 | ) 40 | return mux 41 | } 42 | -------------------------------------------------------------------------------- /cmd/homerunner/setup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | 11 | "github.com/matrix-org/complement/b" 12 | "github.com/matrix-org/complement/internal/docker" 13 | ) 14 | 15 | type Runtime struct { 16 | Config *Config 17 | mu *sync.Mutex 18 | BlueprintToDeployment map[string]*docker.Deployment 19 | BlueprintToTimer map[string]*time.Timer 20 | } 21 | 22 | // NewRuntime makes a homerunner runtime 23 | func NewRuntime(cfg *Config) (*Runtime, error) { 24 | return &Runtime{ 25 | Config: cfg, 26 | BlueprintToDeployment: make(map[string]*docker.Deployment), 27 | BlueprintToTimer: make(map[string]*time.Timer), 28 | mu: &sync.Mutex{}, 29 | }, nil 30 | } 31 | 32 | func (r *Runtime) CreateDeployment(imageURI string, blueprint *b.Blueprint) (*docker.Deployment, time.Time, error) { 33 | duration := time.Duration(r.Config.HomeserverLifetimeMins) * time.Minute 34 | var expires time.Time 35 | if blueprint == nil { 36 | return nil, expires, fmt.Errorf("blueprint must be supplied") 37 | } 38 | namespace := "homerunner_" + blueprint.Name 39 | cfg := r.Config.DeriveComplementConfig(imageURI) 40 | builder, err := docker.NewBuilder(cfg) 41 | if err != nil { 42 | return nil, expires, err 43 | } 44 | if err = builder.ConstructBlueprintIfNotExist(*blueprint); err != nil { 45 | return nil, expires, fmt.Errorf("CreateDeployment: Failed to construct blueprint: %s", err) 46 | } 47 | d, err := docker.NewDeployer(namespace, cfg) 48 | if err != nil { 49 | return nil, expires, fmt.Errorf("CreateDeployment: NewDeployer returned error %s", err) 50 | } 51 | dep, err := d.Deploy(context.Background(), blueprint.Name) 52 | if err != nil { 53 | return nil, expires, fmt.Errorf("CreateDeployment: Deploy returned error %s", err) 54 | } 55 | if err := r.addDeployment(blueprint.Name, dep, duration); err != nil { 56 | return nil, expires, err 57 | } 58 | return dep, time.Now().Add(duration), nil 59 | } 60 | 61 | func (r *Runtime) addDeployment(blueprintName string, d *docker.Deployment, duration time.Duration) error { 62 | r.mu.Lock() 63 | defer r.mu.Unlock() 64 | if _, ok := r.BlueprintToDeployment[blueprintName]; ok { 65 | return fmt.Errorf("deployment with name %s already exists", blueprintName) 66 | } 67 | r.BlueprintToDeployment[blueprintName] = d 68 | r.BlueprintToTimer[blueprintName] = time.AfterFunc(duration, func() { 69 | logrus.Infof("Blueprint '%s' has expired. Tearing down network.", blueprintName) 70 | err := r.DestroyDeployment(blueprintName) 71 | if err != nil { 72 | logrus.WithError(err).Errorf("Failed to tear down expired blueprint '%s'", blueprintName) 73 | } 74 | }) 75 | return nil 76 | } 77 | 78 | func (r *Runtime) DestroyDeployment(blueprintName string) error { 79 | r.mu.Lock() 80 | defer r.mu.Unlock() 81 | d, ok := r.BlueprintToDeployment[blueprintName] 82 | if !ok { 83 | return fmt.Errorf("no deployment with name '%s' exists", blueprintName) 84 | } 85 | d.Deployer.Destroy(d, false, "", false) 86 | delete(r.BlueprintToDeployment, blueprintName) 87 | timer := r.BlueprintToTimer[blueprintName] 88 | timer.Stop() 89 | delete(r.BlueprintToTimer, blueprintName) 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /cmd/homerunner/test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "homerunner-client": "0.0.5" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cmd/homerunner/test/test.mjs: -------------------------------------------------------------------------------- 1 | import { Homerunner } from "homerunner-client"; 2 | 3 | const run = async () => { 4 | const client = new Homerunner.Client(); 5 | console.log("homerunner base url:", client.baseUrl); 6 | console.log("creating homeserver..."); 7 | const blueprint = await client.create({ 8 | base_image_uri: "complement-dendrite", 9 | blueprint_name: "one_to_one_room", 10 | }); 11 | console.log("Client.create responded with", blueprint); 12 | // verify blueprint fields 13 | if (!blueprint.expires) { 14 | throw new Error("missing 'expires' key in response"); 15 | } 16 | const hs1 = blueprint.homeservers["hs1"]; 17 | if (!hs1) { 18 | throw new Error("missing hs1 in response"); 19 | } 20 | const wantKeys = ["BaseURL", "FedBaseURL", "ContainerID", "AccessTokens", "DeviceIDs"]; 21 | wantKeys.forEach((k) => { 22 | if (!hs1[k]) { 23 | throw new Error("hs1 missing key: " + k); 24 | } 25 | }); 26 | 27 | console.log("destroying homeserver..."); 28 | await client.destroy("one_to_one_room"); 29 | }; 30 | 31 | run().then(() => { 32 | process.exit(0); 33 | }).catch((err) => { 34 | console.error("Tests failed:",err.message); 35 | process.exit(1); 36 | }) 37 | -------------------------------------------------------------------------------- /cmd/homerunner/test/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eu 2 | 3 | export HOMERUNNER_PORT=5544 4 | export HOMERUNNER_SPAWN_HS_TIMEOUT_SECS=30 5 | 6 | # build and run homerunner 7 | go build .. 8 | echo 'Running homerunner' 9 | ./homerunner & 10 | HOMERUNNER_PID=$! 11 | # knife homerunner when this script finishes 12 | trap "kill $HOMERUNNER_PID" EXIT 13 | 14 | # wait for homerunner to be listening, we want this endpoint to 404 instead of connrefused 15 | until [ \ 16 | "$(curl -s -w '%{http_code}' -o /dev/null "http://localhost:${HOMERUNNER_PORT}/idonotexist")" \ 17 | -eq 404 ] 18 | do 19 | echo 'Waiting for homerunner to start...' 20 | sleep 1 21 | done 22 | 23 | # build and run the test 24 | echo 'Running tests' 25 | yarn install 26 | node test.mjs 27 | -------------------------------------------------------------------------------- /cmd/homerunner/test/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | cross-fetch@^3.1.5: 6 | version "3.1.5" 7 | resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" 8 | integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== 9 | dependencies: 10 | node-fetch "2.6.7" 11 | 12 | homerunner-client@0.0.5: 13 | version "0.0.5" 14 | resolved "https://registry.yarnpkg.com/homerunner-client/-/homerunner-client-0.0.5.tgz#1fdb696461e044e79842c5a35e60892126c96a04" 15 | integrity sha512-vYxYqPkoQIUScKVmJrgj0P11nM5gtslnMo8o8zdJnE7PIC6qsaEgZMCnU1VeBqkpcOSxIp4H8OknDNtuvlqwFg== 16 | dependencies: 17 | cross-fetch "^3.1.5" 18 | 19 | node-fetch@2.6.7: 20 | version "2.6.7" 21 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" 22 | integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== 23 | dependencies: 24 | whatwg-url "^5.0.0" 25 | 26 | tr46@~0.0.3: 27 | version "0.0.3" 28 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 29 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 30 | 31 | webidl-conversions@^3.0.0: 32 | version "3.0.1" 33 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 34 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 35 | 36 | whatwg-url@^5.0.0: 37 | version "5.0.0" 38 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 39 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 40 | dependencies: 41 | tr46 "~0.0.3" 42 | webidl-conversions "^3.0.0" 43 | -------------------------------------------------------------------------------- /cmd/perfgraph/README.md: -------------------------------------------------------------------------------- 1 | ### Perfgraph 2 | 3 | ``` 4 | go build ./cmd/perfgraph 5 | ./perfgraph a.json b.json c.json 6 | ``` 7 | 8 | Outputs .svg files. Get the `.json` files from `perftest`. -------------------------------------------------------------------------------- /cmd/perfgraph/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "gonum.org/v1/plot" 11 | "gonum.org/v1/plot/font" 12 | "gonum.org/v1/plot/plotter" 13 | "gonum.org/v1/plot/plotutil" 14 | "gonum.org/v1/plot/vg" 15 | ) 16 | 17 | // TODO: remove duplication 18 | type Output struct { 19 | Name string 20 | Snapshots []Snapshot 21 | Seed int64 22 | BaseImage string 23 | } 24 | 25 | type Snapshot struct { 26 | Name string 27 | Description string 28 | HSName string 29 | Duration time.Duration 30 | MemoryUsage uint64 31 | CPUUserland uint64 32 | CPUKernel uint64 33 | TxBytes int64 34 | RxBytes int64 35 | } 36 | 37 | type PerfRun struct { 38 | Snapshots []Snapshot 39 | Name string 40 | } 41 | 42 | func loadFile(filename string) (*PerfRun, error) { 43 | f, err := os.Open(filename) 44 | if err != nil { 45 | return nil, err 46 | } 47 | var s Output 48 | if err = json.NewDecoder(f).Decode(&s); err != nil { 49 | return nil, err 50 | } 51 | name := filename 52 | if s.Name != "" { 53 | name = s.Name 54 | } 55 | return &PerfRun{ 56 | Name: name, 57 | Snapshots: s.Snapshots, 58 | }, nil 59 | } 60 | 61 | func generateCPUGraph(runs []PerfRun, names []string, filename string) { 62 | var groups []plotter.Values 63 | for _, r := range runs { 64 | var g plotter.Values 65 | for _, s := range r.Snapshots { 66 | g = append(g, float64((float64(s.CPUKernel+s.CPUUserland) / float64(time.Millisecond)))) 67 | } 68 | groups = append(groups, g) 69 | } 70 | 71 | p := plot.New() 72 | p.Title.Text = "CPU use over time" 73 | p.Y.Label.Text = "Total (user+kernel) CPU Time (ms)" 74 | 75 | w := vg.Points(20) 76 | offsets := make([]font.Length, len(groups)) 77 | switch len(offsets) { 78 | case 1: 79 | offsets[0] = 0 80 | case 2: 81 | offsets[0] = -0.5 * w 82 | offsets[1] = 0.5 * w 83 | case 3: 84 | offsets[0] = -w 85 | offsets[1] = 0 86 | offsets[2] = w 87 | case 5: 88 | offsets[0] = -2 * w 89 | offsets[1] = -w 90 | offsets[2] = 0 91 | offsets[3] = w 92 | offsets[4] = 2 * w 93 | } 94 | 95 | for i := range groups { 96 | bars, err := plotter.NewBarChart(groups[i], w) 97 | if err != nil { 98 | panic(err) 99 | } 100 | bars.LineStyle.Width = vg.Length(0) 101 | bars.Color = plotutil.Color(i) 102 | bars.Offset = offsets[i] 103 | p.Add(bars) 104 | p.Legend.Add(runs[i].Name, bars) 105 | } 106 | 107 | p.Legend.Top = true 108 | p.Legend.Left = true 109 | p.NominalX(names...) 110 | p.Add(plotter.NewGrid()) 111 | 112 | if err := p.Save(font.Length(float64(len(runs))*float64(len(names))*3*float64(w)), 3*vg.Inch, filename); err != nil { 113 | panic(err) 114 | } 115 | } 116 | 117 | func generateMemoryGraph(runs []PerfRun, names []string, filename string) { 118 | var groups []plotter.Values 119 | for _, r := range runs { 120 | var g plotter.Values 121 | for _, s := range r.Snapshots { 122 | g = append(g, float64((s.MemoryUsage/1024.0)/1024.0)) 123 | } 124 | groups = append(groups, g) 125 | } 126 | 127 | p := plot.New() 128 | p.Title.Text = "Memory use over time" 129 | p.Y.Label.Text = "Memory (MB)" 130 | 131 | w := vg.Points(20) 132 | offsets := make([]font.Length, len(groups)) 133 | switch len(offsets) { 134 | case 1: 135 | offsets[0] = 0 136 | case 2: 137 | offsets[0] = -0.5 * w 138 | offsets[1] = 0.5 * w 139 | case 3: 140 | offsets[0] = -w 141 | offsets[1] = 0 142 | offsets[2] = w 143 | case 5: 144 | offsets[0] = -2 * w 145 | offsets[1] = -w 146 | offsets[2] = 0 147 | offsets[3] = w 148 | offsets[4] = 2 * w 149 | } 150 | 151 | for i := range groups { 152 | bars, err := plotter.NewBarChart(groups[i], w) 153 | if err != nil { 154 | panic(err) 155 | } 156 | bars.LineStyle.Width = vg.Length(0) 157 | bars.Color = plotutil.Color(i) 158 | bars.Offset = offsets[i] 159 | p.Add(bars) 160 | p.Legend.Add(runs[i].Name, bars) 161 | } 162 | 163 | p.Legend.Top = true 164 | p.Legend.Left = true 165 | p.NominalX(names...) 166 | p.Add(plotter.NewGrid()) 167 | 168 | if err := p.Save(font.Length(float64(len(runs))*float64(len(names))*3*float64(w)), 3*vg.Inch, filename); err != nil { 169 | panic(err) 170 | } 171 | } 172 | 173 | func main() { 174 | flag.Parse() 175 | args := flag.Args() 176 | runs := make([]PerfRun, len(args)) 177 | var names []string 178 | for i := range runs { 179 | pr, err := loadFile(args[i]) 180 | if pr == nil { 181 | fmt.Printf("failed to load snapshot from file '%v' : %v\n", args[i], err) 182 | os.Exit(2) 183 | } 184 | // sanity check that the snapshots are for the same thing 185 | if names == nil { 186 | for _, s := range pr.Snapshots { 187 | names = append(names, s.Name) 188 | } 189 | } else { 190 | for i := range pr.Snapshots { 191 | if pr.Snapshots[i].Name != names[i] { 192 | fmt.Printf("snapshots are for different things, cannot make graph: at pos %v %v != %v", i, pr.Snapshots[i].Name, names[i]) 193 | } 194 | } 195 | } 196 | runs[i] = *pr 197 | } 198 | generateMemoryGraph(runs, names, "memory.svg") 199 | generateCPUGraph(runs, names, "cpu.svg") 200 | fmt.Println("Output to memory.svg and cpu.svg") 201 | } 202 | -------------------------------------------------------------------------------- /cmd/perftest/README.md: -------------------------------------------------------------------------------- 1 | ### Performance Test 2 | 3 | ``` 4 | go build ./cmd/perftest 5 | ./perftest -seed 12345 -image complement-synapse:latest -output synapse.json -name 'synapse 1.54' 6 | ``` 7 | 8 | This contains a binary which can run a series of tests on a homeserver implementation and uses `docker stats` to compare: 9 | - CPU usage 10 | - Memory usage 11 | - Network I/O 12 | - Block I/O 13 | 14 | when performing these tests. Currently only 1 test is provided which: 15 | - Registers N users. Does not snapshot anything as cpu/memory/disk/time is intentionally high for things like bcrypt. 16 | - Creates M rooms concurrently. Tests how fast room creation is. 17 | - Joins X users to Y rooms according to a normal distribution. Tests how fast room joins are. 18 | - Sends messages randomly into these rooms. Tests how fast message sending is. 19 | - Syncs all users. Tests how fast initial syncs can be. 20 | - Changes the display name of all users. 21 | - Does an incremental sync on all users. Tests how fast notifier code is. 22 | 23 | This is designed to simulate a small local-only homeserver with a few large rooms with lots of users and a few small rooms. The seed can be fixed 24 | to ensure deterministic results. 25 | -------------------------------------------------------------------------------- /cmd/perftest/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "io/ioutil" 7 | "os" 8 | 9 | "github.com/matrix-org/complement/config" 10 | "github.com/matrix-org/complement/internal/docker" 11 | ) 12 | 13 | var ( 14 | flagName = flag.String("name", "", "The name to attach to this homeserver run. E.g 'dendrite 0.6.4'.") 15 | flagSeed = flag.Int64("seed", 0, "The seed to use for deterministic tests. This allows homeservers to be compared.") 16 | flagImage = flag.String("image", "", "Required. The complement-compatible homserver image to use.") 17 | flagOutput = flag.String("output", "output.json", "Where to write the output data") 18 | ) 19 | 20 | type Output struct { 21 | Name string 22 | Snapshots []Snapshot 23 | Seed int64 24 | BaseImage string 25 | } 26 | 27 | type Config struct { 28 | BaseImage string 29 | Seed int64 30 | } 31 | 32 | func main() { 33 | flag.Parse() 34 | cfg := Config{ 35 | BaseImage: *flagImage, 36 | Seed: *flagSeed, 37 | } 38 | // initialise complement 39 | complementConfig := config.NewConfigFromEnvVars("perf", cfg.BaseImage) 40 | complementConfig.DebugLoggingEnabled = true 41 | 42 | builder, err := docker.NewBuilder(complementConfig) 43 | if err != nil { 44 | panic(err) 45 | } 46 | builder.Cleanup() // remove any previous runs 47 | deployer, err := docker.NewDeployer("perf", complementConfig) 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | // run the test 53 | snapshots, err := runTest("my_test", builder, deployer, cfg.Seed) 54 | if err != nil { 55 | panic(err) 56 | } 57 | b, err := json.Marshal(Output{ 58 | Snapshots: snapshots, 59 | Seed: *flagSeed, 60 | BaseImage: *flagImage, 61 | Name: *flagName, 62 | }) 63 | if err != nil { 64 | panic(err) 65 | } 66 | if err = ioutil.WriteFile(*flagOutput, b, os.ModePerm); err != nil { 67 | panic(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cmd/perftest/snapshot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/docker/docker/api/types/container" 9 | "github.com/matrix-org/complement/internal/docker" 10 | ) 11 | 12 | type Snapshot struct { 13 | Name string 14 | Description string 15 | HSName string 16 | Duration time.Duration 17 | AbsoluteDuration time.Duration 18 | MemoryUsage uint64 19 | CPUUserland uint64 20 | CPUKernel uint64 21 | BytesWritten uint64 22 | BytesRead uint64 23 | TxBytes int64 24 | RxBytes int64 25 | } 26 | 27 | func snapshotStats(spanName, desc string, deployment *docker.Deployment, absDuration, duration time.Duration) (snapshots []Snapshot) { 28 | for hsName, hsInfo := range deployment.HS { 29 | stats, err := deployment.Deployer.Docker.ContainerStatsOneShot(context.Background(), hsInfo.ContainerID) 30 | if err != nil { 31 | return nil 32 | } 33 | var sj container.StatsResponse 34 | err = json.NewDecoder(stats.Body).Decode(&sj) 35 | stats.Body.Close() 36 | if err != nil { 37 | return nil 38 | } 39 | 40 | var rxBytes, txBytes int64 41 | for _, nw := range sj.Networks { 42 | rxBytes += int64(nw.RxBytes) 43 | txBytes += int64(nw.TxBytes) 44 | } 45 | var bw, br uint64 46 | for _, block := range sj.BlkioStats.IoServiceBytesRecursive { 47 | if block.Op == "read" { 48 | br = block.Value 49 | } else if block.Op == "write" { 50 | bw = block.Value 51 | } 52 | } 53 | snapshots = append(snapshots, Snapshot{ 54 | HSName: hsName, 55 | Name: spanName, 56 | Description: desc, 57 | Duration: duration, 58 | AbsoluteDuration: absDuration, 59 | MemoryUsage: sj.MemoryStats.Usage, 60 | CPUUserland: sj.CPUStats.CPUUsage.UsageInUsermode, 61 | CPUKernel: sj.CPUStats.CPUUsage.UsageInKernelmode, 62 | TxBytes: txBytes, 63 | RxBytes: rxBytes, 64 | BytesWritten: bw, 65 | BytesRead: br, 66 | }) 67 | } 68 | return 69 | } 70 | -------------------------------------------------------------------------------- /ct/test.go: -------------------------------------------------------------------------------- 1 | // package ct contains wrappers and interfaces around testing.T 2 | // 3 | // The intention is that _all_ complement functions deal with these wrapper interfaces 4 | // rather than the literal testing.T. This enables Complement to be run in environments 5 | // that aren't strictly via `go test`. 6 | package ct 7 | 8 | // TestLike is an interface that testing.T satisfies. All client functions accept a TestLike interface, 9 | // with the intention of a `testing.T` being passed into them. However, the client may be used in non-test 10 | // scenarios e.g benchmarks, which can then use the same client by just implementing this interface. 11 | type TestLike interface { 12 | Helper() 13 | Logf(msg string, args ...interface{}) 14 | Skipf(msg string, args ...interface{}) 15 | Error(args ...interface{}) 16 | Errorf(msg string, args ...interface{}) 17 | Fatalf(msg string, args ...interface{}) 18 | Failed() bool 19 | Name() string 20 | } 21 | 22 | const ansiRedForeground = "\x1b[31m" 23 | const ansiResetForeground = "\x1b[39m" 24 | 25 | // Errorf is a wrapper around t.Errorf which prints the failing error message in red. 26 | func Errorf(t TestLike, format string, args ...any) { 27 | t.Helper() 28 | format = ansiRedForeground + format + ansiResetForeground 29 | t.Errorf(format, args...) 30 | } 31 | 32 | // Fatalf is a wrapper around t.Fatalf which prints the failing error message in red. 33 | func Fatalf(t TestLike, format string, args ...any) { 34 | t.Helper() 35 | format = ansiRedForeground + format + ansiResetForeground 36 | t.Fatalf(format, args...) 37 | } 38 | -------------------------------------------------------------------------------- /dockerfiles/README.md: -------------------------------------------------------------------------------- 1 | This directory contains a list of dockerfiles which can be used with Complement. 2 | 3 | This used to have stand-alone Dockerfiles which would pull sources directly and build them for testing. 4 | However, this doesn't work nicely when running Complement with local checkouts, so homeservers would 5 | end up copying the Dockerfiles in this directory to their own repository. In an effort to reduce 6 | duplication, we now point to dockerfiles in respective repositories rather than have them directly here. 7 | 8 | - Dendrite: https://github.com/element-hq/dendrite/blob/11b48749bf96fb1f7761df6d7a21cf1cd8484e20/build/scripts/Complement.Dockerfile 9 | - Synapse: https://github.com/matrix-org/synapse/blob/develop/docker/complement/Dockerfile 10 | - Conduit: https://gitlab.com/famedly/conduit/-/blob/next/tests/Complement.Dockerfile 11 | - conduwuit: https://conduwuit.puppyirl.gay/development/testing.html 12 | -------------------------------------------------------------------------------- /federation/server_test.go: -------------------------------------------------------------------------------- 1 | package federation 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/matrix-org/complement/config" 10 | ) 11 | 12 | type fedDeploy struct { 13 | cfg *config.Complement 14 | tripper http.RoundTripper 15 | } 16 | 17 | func (d *fedDeploy) GetConfig() *config.Complement { 18 | return d.cfg 19 | } 20 | func (d *fedDeploy) RoundTripper() http.RoundTripper { 21 | return d.tripper 22 | } 23 | 24 | func TestComplementServerIsSigned(t *testing.T) { 25 | cfg := config.NewConfigFromEnvVars("test", "unimportant") 26 | cfg.HostnameRunningComplement = "localhost" 27 | srv := NewServer(t, &fedDeploy{ 28 | cfg: cfg, 29 | tripper: http.DefaultClient.Transport, 30 | }) 31 | srv.UnexpectedRequestsAreErrors = false 32 | cancel := srv.Listen() 33 | t.Logf("Listening on %s", srv.serverName) 34 | defer cancel() 35 | 36 | caCertPool := x509.NewCertPool() 37 | caCertPool.AddCert(cfg.CACertificate) 38 | 39 | testCases := []struct { 40 | config *tls.Config 41 | wantSuccess bool 42 | }{ 43 | { 44 | config: &tls.Config{ 45 | RootCAs: caCertPool, 46 | }, 47 | wantSuccess: true, 48 | }, 49 | { 50 | config: &tls.Config{}, 51 | wantSuccess: false, 52 | }, 53 | } 54 | for _, tc := range testCases { 55 | transport := &http.Transport{TLSClientConfig: tc.config} 56 | client := &http.Client{Transport: transport} 57 | 58 | resp, err := client.Get("https://" + string(srv.ServerName())) 59 | if err != nil { 60 | if tc.wantSuccess { 61 | t.Fatalf("Failed to GET: %s", err) 62 | } else { 63 | return // wanted failure, got failure 64 | } 65 | } 66 | if !tc.wantSuccess { 67 | t.Fatalf("request succeeded when we expected it to fail") 68 | } 69 | defer resp.Body.Close() 70 | 71 | if resp.StatusCode != 404 { 72 | t.Errorf("expected 404, got %d", resp.StatusCode) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matrix-org/complement 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/docker/docker v28.0.4+incompatible 9 | github.com/docker/go-connections v0.4.0 10 | github.com/gorilla/mux v1.8.0 11 | github.com/matrix-org/gomatrix v0.0.0-20220926102614-ceba4d9f7530 12 | github.com/matrix-org/gomatrixserverlib v0.0.0-20250119093516-0a1b2bafb5cf 13 | github.com/matrix-org/util v0.0.0-20221111132719-399730281e66 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/tidwall/gjson v1.18.0 16 | github.com/tidwall/sjson v1.2.5 17 | golang.org/x/crypto v0.36.0 18 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 19 | gonum.org/v1/plot v0.11.0 20 | ) 21 | 22 | require ( 23 | git.sr.ht/~sbinet/gg v0.3.1 // indirect 24 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 25 | github.com/Microsoft/go-winio v0.5.2 // indirect 26 | github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect 27 | github.com/containerd/log v0.1.0 // indirect 28 | github.com/distribution/reference v0.6.0 // indirect 29 | github.com/docker/go-units v0.4.0 // indirect 30 | github.com/felixge/httpsnoop v1.0.4 // indirect 31 | github.com/go-fonts/liberation v0.2.0 // indirect 32 | github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81 // indirect 33 | github.com/go-logr/logr v1.4.2 // indirect 34 | github.com/go-logr/stdr v1.2.2 // indirect 35 | github.com/go-pdf/fpdf v0.6.0 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 38 | github.com/moby/docker-image-spec v1.3.1 // indirect 39 | github.com/moby/term v0.0.0-20210610120745-9d4ed1856297 // indirect 40 | github.com/morikuni/aec v1.0.0 // indirect 41 | github.com/opencontainers/go-digest v1.0.0 // indirect 42 | github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect 43 | github.com/pkg/errors v0.9.1 // indirect 44 | github.com/tidwall/match v1.1.1 // indirect 45 | github.com/tidwall/pretty v1.2.1 // indirect 46 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect 47 | go.opentelemetry.io/otel v1.30.0 // indirect 48 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect 49 | go.opentelemetry.io/otel/metric v1.30.0 // indirect 50 | go.opentelemetry.io/otel/sdk v1.30.0 // indirect 51 | go.opentelemetry.io/otel/trace v1.30.0 // indirect 52 | golang.org/x/image v0.18.0 // indirect 53 | golang.org/x/net v0.38.0 // indirect 54 | golang.org/x/sys v0.31.0 // indirect 55 | golang.org/x/text v0.23.0 // indirect 56 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 57 | gotest.tools/v3 v3.0.3 // indirect 58 | ) 59 | -------------------------------------------------------------------------------- /helpers/clientopts.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | type RegistrationOpts struct { 4 | LocalpartSuffix string // default '' (don't care) 5 | DeviceID string // default '' (generate new) 6 | Password string // default 'complement_meets_min_password_requirement' 7 | IsAdmin bool // default false 8 | } 9 | 10 | type LoginOpts struct { 11 | Password string // default 'complement_meets_min_password_requirement' 12 | DeviceID string // default '' (generate new) 13 | } 14 | -------------------------------------------------------------------------------- /helpers/waiter.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | 8 | "github.com/matrix-org/complement/ct" 9 | ) 10 | 11 | // Waiter is a simple primitive to wait for a signal asynchronously. It is preferred 12 | // over other sync primitives due to having more sensible defaults such as built-in timeouts 13 | // if the signal does not appear and ability to signal more than once without panicking. 14 | type Waiter struct { 15 | mu sync.Mutex 16 | ch chan bool 17 | closed bool 18 | } 19 | 20 | // NewWaiter returns a generic struct which can be waited on until `Waiter.Finish` is called. 21 | // A Waiter is similar to a `sync.WaitGroup` of size 1, but without the ability to underflow and 22 | // with built-in timeouts. 23 | func NewWaiter() *Waiter { 24 | return &Waiter{ 25 | ch: make(chan bool), 26 | mu: sync.Mutex{}, 27 | } 28 | } 29 | 30 | // Wait blocks until Finish() is called or until the timeout is reached. 31 | // If the timeout is reached, the test is failed. 32 | func (w *Waiter) Wait(t ct.TestLike, timeout time.Duration) { 33 | t.Helper() 34 | w.Waitf(t, timeout, "Wait") 35 | } 36 | 37 | // Waitf blocks until Finish() is called or until the timeout is reached. 38 | // If the timeout is reached, the test is failed with the given error message. 39 | func (w *Waiter) Waitf(t ct.TestLike, timeout time.Duration, errFormat string, args ...interface{}) { 40 | t.Helper() 41 | select { 42 | case <-w.ch: 43 | return 44 | case <-time.After(timeout): 45 | errmsg := fmt.Sprintf(errFormat, args...) 46 | ct.Fatalf(t, "%s: timed out after %f seconds.", errmsg, timeout.Seconds()) 47 | } 48 | } 49 | 50 | // Finish will cause all goroutines waiting via Wait to stop waiting and return. 51 | // Once this function has been called, subsequent calls to Wait will return immediately. 52 | // To begin waiting again, make a new Waiter. 53 | func (w *Waiter) Finish() { 54 | w.mu.Lock() 55 | defer w.mu.Unlock() 56 | if w.closed { 57 | return 58 | } 59 | w.closed = true 60 | close(w.ch) 61 | } 62 | -------------------------------------------------------------------------------- /internal/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import _ "embed" 4 | 5 | //go:embed matrix.png 6 | var MatrixPng []byte 7 | 8 | //go:embed large.png 9 | var LargePng []byte 10 | 11 | //go:embed matrix-logo.svg 12 | var MatrixSvg []byte 13 | -------------------------------------------------------------------------------- /internal/data/large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/complement/6c5077a156d90f75dc6a6d66953652da536178e3/internal/data/large.png -------------------------------------------------------------------------------- /internal/data/matrix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/complement/6c5077a156d90f75dc6a6d66953652da536178e3/internal/data/matrix.png -------------------------------------------------------------------------------- /internal/docker/labels.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/docker/docker/api/types/filters" 7 | 8 | "github.com/matrix-org/complement/b" 9 | ) 10 | 11 | // label returns a filter for the presence of certain labels ("complement_context") or a match of 12 | // labels ("complement_blueprint=foo"). 13 | func label(labelFilters ...string) filters.Args { 14 | f := filters.NewArgs() 15 | // label= or label== 16 | for _, in := range labelFilters { 17 | f.Add("label", in) 18 | } 19 | return f 20 | } 21 | 22 | func tokensFromLabels(labels map[string]string) map[string]string { 23 | userIDToToken := make(map[string]string) 24 | for k, v := range labels { 25 | if strings.HasPrefix(k, "access_token_") { 26 | userIDToToken[strings.TrimPrefix(k, "access_token_")] = v 27 | } 28 | } 29 | return userIDToToken 30 | } 31 | 32 | func asIDToRegistrationFromLabels(labels map[string]string) map[string]string { 33 | asMap := make(map[string]string) 34 | for k, v := range labels { 35 | if strings.HasPrefix(k, "application_service_") { 36 | // cf comment of generateASRegistrationYaml for ReplaceAll explanation 37 | asMap[strings.TrimPrefix(k, "application_service_")] = strings.ReplaceAll(v, "\\n", "\n") 38 | } 39 | } 40 | return asMap 41 | } 42 | 43 | func labelsForApplicationServices(hs b.Homeserver) map[string]string { 44 | labels := make(map[string]string) 45 | // collect and store app service registrations as labels 'application_service_$as_id: $registration' 46 | // collect and store app service access tokens as labels 'access_token_$sender_localpart: $as_token' 47 | for _, as := range hs.ApplicationServices { 48 | labels["application_service_"+as.ID] = generateASRegistrationYaml(as) 49 | 50 | labels["access_token_@"+as.SenderLocalpart+":"+hs.Name] = as.ASToken 51 | } 52 | return labels 53 | } 54 | 55 | func deviceIDsFromLabels(labels map[string]string) map[string]string { 56 | userIDToToken := make(map[string]string) 57 | for k, v := range labels { 58 | if strings.HasPrefix(k, "device_id") { 59 | userIDToToken[strings.TrimPrefix(k, "device_id")] = v 60 | } 61 | } 62 | return userIDToToken 63 | } 64 | -------------------------------------------------------------------------------- /internal/web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/gorilla/mux" 10 | 11 | "github.com/matrix-org/complement/config" 12 | ) 13 | 14 | type Server struct { 15 | URL string 16 | Port int 17 | server *http.Server 18 | listener net.Listener 19 | } 20 | 21 | func NewServer(t *testing.T, comp *config.Complement, configFunc func(router *mux.Router)) *Server { 22 | t.Helper() 23 | 24 | listener, err := net.Listen("tcp", ":0") 25 | if err != nil { 26 | t.Fatalf("Could not create listener for web server: %s", err) 27 | } 28 | 29 | port := listener.Addr().(*net.TCPAddr).Port 30 | 31 | r := mux.NewRouter() 32 | 33 | configFunc(r) 34 | 35 | server := &http.Server{Addr: ":0", Handler: r} 36 | 37 | go server.Serve(listener) 38 | 39 | return &Server{ 40 | URL: fmt.Sprintf("http://%s:%d", comp.HostnameRunningComplement, port), 41 | Port: port, 42 | server: server, 43 | listener: listener, 44 | } 45 | } 46 | 47 | func (s *Server) Close() { 48 | s.server.Close() 49 | s.listener.Close() 50 | } 51 | -------------------------------------------------------------------------------- /match/http.go: -------------------------------------------------------------------------------- 1 | package match 2 | 3 | // HTTPResponse is the desired shape of the HTTP response. Can include any number of JSON matchers. 4 | type HTTPResponse struct { 5 | StatusCode int 6 | Headers map[string]string 7 | JSON []JSON 8 | } 9 | 10 | // HTTPRequest is the desired shape of the HTTP request. Can include any number of JSON matchers. 11 | type HTTPRequest struct { 12 | Headers map[string]string 13 | JSON []JSON 14 | } 15 | -------------------------------------------------------------------------------- /runtime/hs.go: -------------------------------------------------------------------------------- 1 | package runtime 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/client" 7 | "github.com/matrix-org/complement/ct" 8 | ) 9 | 10 | const ( 11 | Dendrite = "dendrite" 12 | Synapse = "synapse" 13 | Conduit = "conduit" 14 | Conduwuit = "conduwuit" 15 | ) 16 | 17 | var Homeserver string 18 | 19 | // ContainerKillFunc is used to destroy a container, it can be overwritten by Homeserver implementations 20 | // to e.g. gracefully stop a container. 21 | var ContainerKillFunc = func(client *client.Client, containerID string) error { 22 | return client.ContainerKill(context.Background(), containerID, "KILL") 23 | } 24 | 25 | // Skip the test (via t.Skipf) if the homeserver being tested matches one of the homeservers, else return. 26 | // 27 | // The homeserver being tested is detected via the presence of a `*_blacklist` tag e.g: 28 | // 29 | // go test -tags="dendrite_blacklist" 30 | // 31 | // This means it is important to always specify this tag when running tests. Failure to do 32 | // so will result in a warning being printed to stdout, and the test will be run. When a new server 33 | // implementation is added, a respective `hs_$name.go` needs to be created in this directory. This 34 | // file pairs together the tag name with a string constant declared in this package 35 | // e.g. dendrite_blacklist == runtime.Dendrite 36 | func SkipIf(t ct.TestLike, hses ...string) { 37 | t.Helper() 38 | for _, hs := range hses { 39 | if Homeserver == hs { 40 | t.Skipf("skipped on %s", hs) 41 | return 42 | } 43 | } 44 | if Homeserver == "" { 45 | // they ran Complement without a blacklist so it's impossible to know what HS they are 46 | // running, warn them. 47 | t.Logf( 48 | "WARNING: %s called runtime.SkipIf(%v) but Complement doesn't know which HS is running as it was run without a *_blacklist tag: executing test.", 49 | t.Name(), hses, 50 | ) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /runtime/hs_conduit.go: -------------------------------------------------------------------------------- 1 | // +build conduit_blacklist 2 | 3 | package runtime 4 | 5 | func init() { 6 | Homeserver = Conduit 7 | } 8 | -------------------------------------------------------------------------------- /runtime/hs_conduwuit.go: -------------------------------------------------------------------------------- 1 | //go:build conduwuit_blacklist || conduit_blacklist 2 | // +build conduwuit_blacklist conduit_blacklist 3 | 4 | // for now, a couple skipped conduit tests still apply to conduwuit. this will change in the future 5 | 6 | package runtime 7 | 8 | func init() { 9 | Homeserver = Conduwuit 10 | } 11 | -------------------------------------------------------------------------------- /runtime/hs_dendrite.go: -------------------------------------------------------------------------------- 1 | //go:build dendrite_blacklist 2 | // +build dendrite_blacklist 3 | 4 | package runtime 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/docker/docker/api/types/container" 10 | "github.com/docker/docker/client" 11 | ) 12 | 13 | func init() { 14 | Homeserver = Dendrite 15 | // For Dendrite, we want to always stop the container gracefully, as this is needed to 16 | // extract e.g. coverage reports. 17 | ContainerKillFunc = func(client *client.Client, containerID string) error { 18 | oneSecond := 1 19 | return client.ContainerStop(context.Background(), containerID, container.StopOptions{ 20 | Timeout: &oneSecond, 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /runtime/hs_synapse.go: -------------------------------------------------------------------------------- 1 | // +build synapse_blacklist 2 | 3 | package runtime 4 | 5 | func init() { 6 | Homeserver = Synapse 7 | } 8 | -------------------------------------------------------------------------------- /sytest.ignored.list: -------------------------------------------------------------------------------- 1 | # These list all of the sytests that're ignored, line-by-line 2 | # Complete with comments as to why particular tests were ignored 3 | 4 | # Dummy test in 00prepare.pl 5 | foo 6 | Checking local federation server 7 | 8 | # Tests synapse admin API 9 | Can quarantine media in rooms 10 | /purge_history 11 | /purge_history by ts 12 | Shutdown room 13 | Can backfill purged history 14 | 15 | # Dummy test that exists only to prove a capability 16 | # (in 10apidoc/36room-levels) 17 | Both GET and PUT work 18 | 19 | # Tests deprecated endpoints 20 | Tags appear in the v1 /initialSync 21 | Tags appear in the v1 /events stream 22 | Tags appear in the v1 room initial sync 23 | Account data appears in v1 /events stream 24 | Latest account data comes down in /initialSync 25 | Latest account data comes down in room initialSync 26 | Room account data appears in v1 /events stream 27 | GET /events with negative 'limit' 28 | GET /events with non-numeric 'limit' 29 | GET /events with non-numeric 'timeout' 30 | GET /initialSync with non-numeric 'limit' 31 | GET /events initially 32 | GET /initialSync initially 33 | GET /rooms/:room_id/initialSync fetches initial sync state 34 | All room members see all room members' presence in global initialSync 35 | New room members see existing users' presence in room initialSync 36 | New room members see existing members' presence in room initialSync 37 | New room members see first user's profile information in global initialSync 38 | New room members see first user's profile information in per-room initialSync 39 | A departed room is still included in /initialSync (SPEC-216) 40 | Can get rooms/{roomId}/initialSync for a departed room (SPEC-216) 41 | initialSync sees my presence status 42 | Global initialSync 43 | Global initialSync with limit=0 gives no messages 44 | Room initialSync 45 | Room initialSync with limit=0 gives no messages 46 | Read receipts are visible to /initialSync 47 | Newly created users see their own presence in /initialSync (SYT-34) 48 | Guest user calling /events doesn't tightloop 49 | Guest user cannot call /events globally 50 | !53groups -------------------------------------------------------------------------------- /test_main.go: -------------------------------------------------------------------------------- 1 | package complement 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/matrix-org/complement/b" 9 | "github.com/matrix-org/complement/config" 10 | "github.com/matrix-org/complement/ct" 11 | ) 12 | 13 | var ( 14 | testPackage *TestPackage 15 | customDeployer func(t ct.TestLike, numServers int, config *config.Complement) Deployment 16 | ) 17 | 18 | type complementOpts struct { 19 | // Args: 20 | // - We pass in the Complement config (`testPackage.Config`) so the deployer can inspect 21 | // `DebugLoggingEnabled`, `SpawnHSTimeout`, `PackageNamespace` etc. 22 | cleanup func(config *config.Complement) 23 | // Args: 24 | // - We pass in `t` as there needs to be a way to handle an error in the custom deployer. 25 | // - We pass in the Complement config (`testPackage.Config`) so the deployer can inspect 26 | // `DebugLoggingEnabled`, `SpawnHSTimeout`, `PackageNamespace`, etc. 27 | customDeployment func(t ct.TestLike, numServers int, config *config.Complement) Deployment 28 | } 29 | type opt func(*complementOpts) 30 | 31 | // WithCleanup adds a cleanup function which is called prior to terminating the test suite. 32 | // It is called BEFORE Complement containers are destroyed. 33 | // This function should be used for per-suite cleanup operations e.g tearing down containers, killing 34 | // child processes, etc. 35 | func WithCleanup(fn func(config *config.Complement)) opt { 36 | return func(co *complementOpts) { 37 | co.cleanup = fn 38 | } 39 | } 40 | 41 | // WithDeployment adds a custom mechanism to deploy homeservers. 42 | // 43 | // For test consistency and compatibility, deployers should be creating servers that can 44 | // be referred to as `hs1`, `hs2`, etc as the `hsName` in the `Deployment` interface. 45 | // The actual resolvable address of the homeserver in the network can be something 46 | // different and just needs to be mapped by 47 | // your implementation of `deployment.GetFullyQualifiedHomeserverName(hsName)`. 48 | func WithDeployment(fn func(t ct.TestLike, numServers int, config *config.Complement) Deployment) opt { 49 | return func(co *complementOpts) { 50 | co.customDeployment = fn 51 | } 52 | } 53 | 54 | // TestMain is the main entry point for Complement. 55 | // 56 | // It will clean up any old containers/images/networks from the previous run, then run the tests, then clean up 57 | // again. No blueprints are made at this point as they are lazily made on demand. 58 | // 59 | // The 'namespace' should be unique for this test package, among all test packages which may run in parallel, to avoid 60 | // docker containers stepping on each other. For MSCs, use the MSC name. For versioned releases, use the version number 61 | // along with any sub-directory name. 62 | // 63 | // Functional options can be used to control how Complement processes deployments. 64 | func TestMain(m *testing.M, namespace string, customOpts ...opt) { 65 | opts := &complementOpts{} 66 | for _, o := range customOpts { 67 | o(opts) 68 | } 69 | if opts.customDeployment != nil { 70 | customDeployer = opts.customDeployment 71 | } 72 | 73 | var err error 74 | testPackage, err = NewTestPackage(namespace) 75 | if err != nil { 76 | fmt.Printf("Error: %s", err) 77 | os.Exit(1) 78 | } 79 | exitCode := m.Run() 80 | if opts.cleanup != nil { 81 | opts.cleanup(testPackage.Config) 82 | } 83 | testPackage.Cleanup() 84 | os.Exit(exitCode) 85 | } 86 | 87 | // Deploy will deploy the given blueprint or terminate the test. 88 | // It will construct the blueprint if it doesn't already exist in the docker image cache. 89 | // This function is the main setup function for all tests as it provides a deployment with 90 | // which tests can interact with. 91 | func OldDeploy(t ct.TestLike, blueprint b.Blueprint) Deployment { 92 | t.Helper() 93 | if testPackage == nil { 94 | ct.Fatalf(t, "Deploy: testPackage not set, did you forget to call complement.TestMain?") 95 | } 96 | return testPackage.OldDeploy(t, blueprint) 97 | } 98 | 99 | // Deploy will deploy the given number of servers or terminate the test. 100 | // This function is the main setup function for all tests as it provides a deployment with 101 | // which tests can interact with. 102 | // 103 | // For test consistency and compatibility, deployers should be creating servers that can 104 | // be referred to as `hs1`, `hs2`, etc as the `hsName` in the `Deployment` interface. 105 | func Deploy(t ct.TestLike, numServers int) Deployment { 106 | t.Helper() 107 | if testPackage == nil { 108 | ct.Fatalf(t, "Deploy: testPackage not set, did you forget to call complement.TestMain?") 109 | } 110 | if customDeployer != nil { 111 | return customDeployer(t, numServers, testPackage.Config) 112 | } 113 | return testPackage.Deploy(t, numServers) 114 | } 115 | -------------------------------------------------------------------------------- /tests/csapi/account_change_password_pushers_test.go: -------------------------------------------------------------------------------- 1 | //go:build !dendrite_blacklist 2 | // +build !dendrite_blacklist 3 | 4 | package csapi_tests 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | 15 | "github.com/tidwall/gjson" 16 | ) 17 | 18 | func TestChangePasswordPushers(t *testing.T) { 19 | deployment := complement.Deploy(t, 1) 20 | defer deployment.Destroy(t) 21 | password1 := "superuser" 22 | password2 := "my_new_password" 23 | passwordClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 24 | Password: password1, 25 | }) 26 | 27 | // sytest: Pushers created with a different access token are deleted on password change 28 | t.Run("Pushers created with a different access token are deleted on password change", func(t *testing.T) { 29 | _, sessionOptional := createSession(t, deployment, passwordClient.UserID, password1) 30 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 31 | "data": map[string]interface{}{ 32 | "url": "https://dummy.url/_matrix/push/v1/notify", 33 | }, 34 | "profile_tag": "tag", 35 | "kind": "http", 36 | "app_id": "complement", 37 | "app_display_name": "complement_display_name", 38 | "device_display_name": "device_display_name", 39 | "pushkey": "a_push_key", 40 | "lang": "en", 41 | }) 42 | 43 | _ = sessionOptional.MustDo(t, "POST", []string{"_matrix", "client", "v3", "pushers", "set"}, reqBody) 44 | 45 | changePassword(t, passwordClient, password1, password2) 46 | 47 | pushersSize := 0 48 | 49 | res := passwordClient.Do(t, "GET", []string{"_matrix", "client", "v3", "pushers"}) 50 | must.MatchResponse(t, res, match.HTTPResponse{ 51 | StatusCode: 200, 52 | JSON: []match.JSON{ 53 | match.JSONArrayEach("pushers", func(val gjson.Result) error { 54 | pushersSize++ 55 | return nil 56 | }), 57 | }, 58 | }) 59 | if pushersSize != 0 { 60 | t.Errorf("pushers size expected to be 0, found %d", pushersSize) 61 | } 62 | }) 63 | 64 | // sytest: Pushers created with a the same access token are not deleted on password change 65 | t.Run("Pushers created with the same access token are not deleted on password change", func(t *testing.T) { 66 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 67 | "data": map[string]interface{}{ 68 | "url": "https://dummy.url/_matrix/push/v1/notify", 69 | }, 70 | "profile_tag": "tag", 71 | "kind": "http", 72 | "app_id": "complement", 73 | "app_display_name": "complement_display_name", 74 | "device_display_name": "device_display_name", 75 | "pushkey": "a_push_key", 76 | "lang": "en", 77 | }) 78 | 79 | _ = passwordClient.MustDo(t, "POST", []string{"_matrix", "client", "v3", "pushers", "set"}, reqBody) 80 | 81 | changePassword(t, passwordClient, password2, password1) 82 | 83 | pushersSize := 0 84 | 85 | res := passwordClient.Do(t, "GET", []string{"_matrix", "client", "v3", "pushers"}) 86 | must.MatchResponse(t, res, match.HTTPResponse{ 87 | StatusCode: 200, 88 | JSON: []match.JSON{ 89 | match.JSONArrayEach("pushers", func(val gjson.Result) error { 90 | pushersSize++ 91 | return nil 92 | }), 93 | }, 94 | }) 95 | if pushersSize != 1 { 96 | t.Errorf("pushers size expected to be 1, found %d", pushersSize) 97 | } 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /tests/csapi/account_data_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/helpers" 8 | "github.com/matrix-org/complement/match" 9 | "github.com/matrix-org/complement/must" 10 | ) 11 | 12 | func TestAddAccountData(t *testing.T) { 13 | deployment := complement.Deploy(t, 1) 14 | defer deployment.Destroy(t) 15 | 16 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 17 | 18 | // sytest: Can add account data 19 | // sytest: Can get account data without syncing 20 | t.Run("Can add global account data", func(t *testing.T) { 21 | // Set the account data entry 22 | alice.MustSetGlobalAccountData(t, "test.key", map[string]interface{}{"value": "first"}) 23 | 24 | // check that getting the account data returns the correct value 25 | must.MatchResponse(t, alice.MustGetGlobalAccountData(t, "test.key"), match.HTTPResponse{ 26 | JSON: []match.JSON{ 27 | match.JSONKeyEqual("value", "first"), 28 | }, 29 | }) 30 | 31 | // Set it to something else 32 | alice.MustSetGlobalAccountData(t, "test.key", map[string]interface{}{"value": "second"}) 33 | 34 | // check that getting the account data returns the updated value 35 | must.MatchResponse(t, alice.MustGetGlobalAccountData(t, "test.key"), match.HTTPResponse{ 36 | JSON: []match.JSON{ 37 | match.JSONKeyEqual("value", "second"), 38 | }, 39 | }) 40 | }) 41 | 42 | // sytest: Can add account data to room 43 | // sytest: Can get room account data without syncing 44 | t.Run("Can add room account data", func(t *testing.T) { 45 | // Create a room 46 | roomID := alice.MustCreateRoom(t, map[string]interface{}{}) 47 | 48 | // Set the room account data entry 49 | alice.MustSetRoomAccountData(t, roomID, "test.key", map[string]interface{}{"value": "room first"}) 50 | 51 | // check that getting the account data returns the correct value 52 | must.MatchResponse(t, alice.MustGetRoomAccountData(t, roomID, "test.key"), match.HTTPResponse{ 53 | JSON: []match.JSON{ 54 | match.JSONKeyEqual("value", "room first"), 55 | }, 56 | }) 57 | 58 | // Set it to something else 59 | alice.MustSetRoomAccountData(t, roomID, "test.key", map[string]interface{}{"value": "room second"}) 60 | 61 | // check that getting the account data returns the updated value 62 | must.MatchResponse(t, alice.MustGetRoomAccountData(t, roomID, "test.key"), match.HTTPResponse{ 63 | JSON: []match.JSON{ 64 | match.JSONKeyEqual("value", "room second"), 65 | }, 66 | }) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /tests/csapi/account_deactivate_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/tidwall/gjson" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | ) 15 | 16 | func TestDeactivateAccount(t *testing.T) { 17 | deployment := complement.Deploy(t, 1) 18 | defer deployment.Destroy(t) 19 | password := "superuser" 20 | authedClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 21 | Password: password, 22 | }) 23 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 24 | 25 | // Ensure that the first step, in which the client queries the server's user-interactive auth flows, returns 26 | // at least one auth flow involving a password. 27 | t.Run("Password flow is available", func(t *testing.T) { 28 | reqBody := client.WithJSONBody(t, map[string]interface{}{}) 29 | res := authedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "account", "deactivate"}, reqBody) 30 | 31 | rawBody := must.MatchResponse(t, res, match.HTTPResponse{ 32 | StatusCode: 401, 33 | }) 34 | body := gjson.ParseBytes(rawBody) 35 | 36 | // Example: {"session":"wombat","flows":[{"stages":["m.login.password"]}],"params":{}} 37 | t.Logf("Received JSON %s", body.String()) 38 | 39 | flowList, ok := body.Get("flows").Value().([]interface{}) 40 | if !ok { 41 | t.Fatalf("flows is not a list") 42 | return 43 | } 44 | 45 | foundPasswordStage := false 46 | 47 | outer: 48 | for _, flow := range flowList { 49 | flowObject, ok := flow.(map[string]interface{}) 50 | stageList, ok := flowObject["stages"].([]interface{}) 51 | if !ok { 52 | t.Fatalf("stages is not a list") 53 | return 54 | } 55 | 56 | for _, stage := range stageList { 57 | stageName, ok := stage.(string) 58 | if !ok { 59 | t.Fatalf("stage is not a string") 60 | return 61 | } 62 | if stageName == "m.login.password" { 63 | foundPasswordStage = true 64 | break outer 65 | } 66 | } 67 | } 68 | 69 | if !foundPasswordStage { 70 | t.Errorf("No m.login.password login stages found.") 71 | } 72 | }) 73 | 74 | // sytest: Can't deactivate account with wrong password 75 | t.Run("Can't deactivate account with wrong password", func(t *testing.T) { 76 | res := deactivateAccount(t, authedClient, "wrong_password") 77 | must.MatchResponse(t, res, match.HTTPResponse{ 78 | StatusCode: 401, 79 | JSON: []match.JSON{ 80 | match.JSONKeyEqual("errcode", "M_FORBIDDEN"), 81 | }, 82 | }) 83 | }) 84 | // sytest: Can deactivate account 85 | t.Run("Can deactivate account", func(t *testing.T) { 86 | 87 | res := deactivateAccount(t, authedClient, password) 88 | must.MatchResponse(t, res, match.HTTPResponse{ 89 | StatusCode: 200, 90 | }) 91 | }) 92 | // sytest: After deactivating account, can't log in with password 93 | t.Run("After deactivating account, can't log in with password", func(t *testing.T) { 94 | 95 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 96 | "identifier": map[string]interface{}{ 97 | "type": "m.id.user", 98 | "user": authedClient.UserID, 99 | }, 100 | "type": "m.login.password", 101 | "password": password, 102 | }) 103 | res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "login"}, reqBody) 104 | must.MatchResponse(t, res, match.HTTPResponse{ 105 | StatusCode: 403, 106 | }) 107 | }) 108 | } 109 | 110 | func deactivateAccount(t *testing.T, authedClient *client.CSAPI, password string) *http.Response { 111 | t.Helper() 112 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 113 | "auth": map[string]interface{}{ 114 | "type": "m.login.password", 115 | "identifier": map[string]interface{}{ 116 | "type": "m.id.user", 117 | "user": authedClient.UserID, 118 | }, 119 | "password": password, 120 | }, 121 | }) 122 | 123 | res := authedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "account", "deactivate"}, reqBody) 124 | 125 | return res 126 | } 127 | -------------------------------------------------------------------------------- /tests/csapi/admin_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/tidwall/gjson" 9 | 10 | "github.com/matrix-org/complement" 11 | "github.com/matrix-org/complement/client" 12 | "github.com/matrix-org/complement/helpers" 13 | "github.com/matrix-org/complement/match" 14 | "github.com/matrix-org/complement/must" 15 | "github.com/matrix-org/gomatrixserverlib/spec" 16 | ) 17 | 18 | // Check if this homeserver supports Synapse-style admin registration. 19 | // Not all images support this currently. 20 | func TestCanRegisterAdmin(t *testing.T) { 21 | deployment := complement.Deploy(t, 1) 22 | defer deployment.Destroy(t) 23 | deployment.Register(t, "hs1", helpers.RegistrationOpts{ 24 | IsAdmin: true, 25 | }) 26 | } 27 | 28 | // Test if the implemented /_synapse/admin/v1/send_server_notice behaves as expected 29 | func TestServerNotices(t *testing.T) { 30 | deployment := complement.Deploy(t, 1) 31 | defer deployment.Destroy(t) 32 | admin := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 33 | IsAdmin: true, 34 | }) 35 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 36 | 37 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 38 | "user_id": alice.UserID, 39 | "content": map[string]interface{}{ 40 | "msgtype": "m.text", 41 | "body": "hello from server notices!", 42 | }, 43 | }) 44 | var ( 45 | eventID string 46 | roomID string 47 | ) 48 | t.Run("/send_server_notice is not allowed as normal user", func(t *testing.T) { 49 | res := alice.Do(t, "POST", []string{"_synapse", "admin", "v1", "send_server_notice"}) 50 | must.MatchResponse(t, res, match.HTTPResponse{ 51 | StatusCode: http.StatusForbidden, 52 | JSON: []match.JSON{ 53 | match.JSONKeyEqual("errcode", "M_FORBIDDEN"), 54 | }, 55 | }) 56 | }) 57 | t.Run("/send_server_notice as an admin is allowed", func(t *testing.T) { 58 | eventID = sendServerNotice(t, admin, reqBody, nil) 59 | }) 60 | t.Run("Alice is invited to the server alert room", func(t *testing.T) { 61 | roomID = syncUntilInvite(t, alice) 62 | }) 63 | t.Run("Alice cannot reject the invite", func(t *testing.T) { 64 | res := alice.LeaveRoom(t, roomID) 65 | must.MatchResponse(t, res, match.HTTPResponse{ 66 | StatusCode: http.StatusForbidden, 67 | JSON: []match.JSON{ 68 | match.JSONKeyEqual("errcode", "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM"), 69 | }, 70 | }) 71 | }) 72 | t.Run("Alice can join the alert room", func(t *testing.T) { 73 | alice.MustJoinRoom(t, roomID, []spec.ServerName{}) 74 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, eventID)) 75 | }) 76 | t.Run("Alice can leave the alert room, after joining it", func(t *testing.T) { 77 | alice.MustLeaveRoom(t, roomID) 78 | }) 79 | t.Run("After leaving the alert room and on re-invitation, no new room is created", func(t *testing.T) { 80 | sendServerNotice(t, admin, reqBody, nil) 81 | newRoomID := syncUntilInvite(t, alice) 82 | if roomID != newRoomID { 83 | t.Errorf("expected no new room but got one: %s != %s", roomID, newRoomID) 84 | } 85 | }) 86 | t.Run("Sending a notice with a transactionID is idempotent", func(t *testing.T) { 87 | txnID := "1" 88 | eventID1 := sendServerNotice(t, admin, reqBody, &txnID) 89 | eventID2 := sendServerNotice(t, admin, reqBody, &txnID) 90 | if eventID1 != eventID2 { 91 | t.Errorf("expected event IDs to be the same, but got '%s' and '%s'", eventID1, eventID2) 92 | } 93 | }) 94 | 95 | } 96 | 97 | func sendServerNotice(t *testing.T, admin *client.CSAPI, reqBody client.RequestOpt, txnID *string) (eventID string) { 98 | var res *http.Response 99 | if txnID != nil { 100 | res = admin.MustDo(t, "PUT", []string{"_synapse", "admin", "v1", "send_server_notice", *txnID}, reqBody) 101 | } else { 102 | res = admin.MustDo(t, "POST", []string{"_synapse", "admin", "v1", "send_server_notice"}, reqBody) 103 | } 104 | body := must.MatchResponse(t, res, match.HTTPResponse{ 105 | StatusCode: http.StatusOK, 106 | JSON: []match.JSON{ 107 | match.JSONKeyPresent("event_id"), 108 | }, 109 | }) 110 | return gjson.GetBytes(body, "event_id").Str 111 | } 112 | 113 | // syncUntilInvite checks if we got an invitation from the server notice sender, as the roomID is unknown. 114 | // Returns the found roomID on success 115 | func syncUntilInvite(t *testing.T, alice *client.CSAPI) string { 116 | var roomID string 117 | alice.MustSyncUntil(t, client.SyncReq{}, func(userID string, res gjson.Result) error { 118 | if res.Get("rooms.invite.*.invite_state.events.0.sender").Str == "@_server:hs1" { 119 | roomID = res.Get("rooms.invite").Get("@keys.0").Str 120 | return nil 121 | } 122 | return fmt.Errorf("invite not found") 123 | }) 124 | return roomID 125 | } 126 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_content_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/matrix-org/complement" 9 | "github.com/matrix-org/complement/helpers" 10 | "github.com/matrix-org/complement/internal/data" 11 | "github.com/matrix-org/complement/runtime" 12 | ) 13 | 14 | func TestContent(t *testing.T) { 15 | // Synapse no longer allows downloads over the unauthenticated media endpoints by default 16 | runtime.SkipIf(t, runtime.Synapse) 17 | 18 | deployment := complement.Deploy(t, 1) 19 | defer deployment.Destroy(t) 20 | 21 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 22 | wantContentType := "image/png" 23 | // sytest: POST /media/v3/upload can create an upload 24 | mxcUri := alice.UploadContent(t, data.MatrixPng, "test.png", wantContentType) 25 | 26 | // sytest: GET /media/v3/download can fetch the value again 27 | content, contentType := alice.DownloadContent(t, mxcUri) 28 | if !bytes.Equal(data.MatrixPng, content) { 29 | t.Fatalf("uploaded and downloaded content doesn't match: want %v\ngot\n%v", data.MatrixPng, content) 30 | } 31 | if contentType != wantContentType { 32 | t.Fatalf("expected contentType to be %s, got %s", wantContentType, contentType) 33 | } 34 | } 35 | 36 | // same as above but testing _matrix/client/v1/media/download 37 | func TestContentCSAPIMediaV1(t *testing.T) { 38 | deployment := complement.Deploy(t, 1) 39 | defer deployment.Destroy(t) 40 | 41 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 42 | 43 | wantContentType := "img/png" 44 | mxcUri := alice.UploadContent(t, data.MatrixPng, "test.png", wantContentType) 45 | 46 | content, contentType := alice.DownloadContentAuthenticated(t, mxcUri) 47 | if !bytes.Equal(data.MatrixPng, content) { 48 | t.Fatalf("uploaded and downloaded content doesn't match: want %v\ngot\n%v", data.MatrixPng, content) 49 | } 50 | if contentType != wantContentType { 51 | t.Fatalf("expected contentType to be \n %s, got \n %s", wantContentType, contentType) 52 | } 53 | 54 | // Remove the AccessToken and try again, this should now return a 401. 55 | alice.AccessToken = "" 56 | res := alice.Do(t, "GET", []string{"_matrix", "client", "v1", "media", "download", "hs1", mxcUri}) 57 | if res.StatusCode != http.StatusUnauthorized { 58 | t.Fatalf("expected HTTP status: %d, got %d", http.StatusUnauthorized, res.StatusCode) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_logout_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/tidwall/gjson" 9 | 10 | "github.com/matrix-org/complement" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | ) 15 | 16 | func TestLogout(t *testing.T) { 17 | deployment := complement.Deploy(t, 1) 18 | defer deployment.Destroy(t) 19 | 20 | password := "superuser" 21 | verifyClientUser := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 22 | Password: password, 23 | }) 24 | 25 | // sytest: Can logout current device 26 | t.Run("Can logout current device", func(t *testing.T) { 27 | deviceID, clientToLogout := createSession(t, deployment, verifyClientUser.UserID, password) 28 | res := clientToLogout.MustDo(t, "GET", []string{"_matrix", "client", "v3", "devices"}) 29 | must.MatchResponse(t, res, match.HTTPResponse{ 30 | JSON: []match.JSON{ 31 | match.JSONKeyArrayOfSize("devices", 2), 32 | }, 33 | }) 34 | res = clientToLogout.MustDo(t, "POST", []string{"_matrix", "client", "v3", "logout"}) 35 | // the session should be invalidated 36 | res = clientToLogout.Do(t, "GET", []string{"_matrix", "client", "v3", "sync"}) 37 | must.MatchResponse(t, res, match.HTTPResponse{StatusCode: http.StatusUnauthorized}) 38 | // verify with first device 39 | res = verifyClientUser.MustDo(t, "GET", []string{"_matrix", "client", "v3", "devices"}) 40 | must.MatchResponse(t, res, match.HTTPResponse{ 41 | JSON: []match.JSON{ 42 | match.JSONKeyArrayOfSize("devices", 1), 43 | match.JSONArrayEach("devices", func(result gjson.Result) error { 44 | if result.Get("device_id").Str == deviceID { 45 | return fmt.Errorf("second device still exists") 46 | } 47 | return nil 48 | }), 49 | }, 50 | }) 51 | }) 52 | // sytest: Can logout all devices 53 | t.Run("Can logout all devices", func(t *testing.T) { 54 | _, clientToLogout := createSession(t, deployment, verifyClientUser.UserID, password) 55 | res := clientToLogout.MustDo(t, "GET", []string{"_matrix", "client", "v3", "devices"}) 56 | must.MatchResponse(t, res, match.HTTPResponse{ 57 | JSON: []match.JSON{ 58 | match.JSONKeyArrayOfSize("devices", 2), 59 | }, 60 | }) 61 | res = clientToLogout.MustDo(t, "POST", []string{"_matrix", "client", "v3", "logout", "all"}) 62 | must.MatchResponse(t, res, match.HTTPResponse{StatusCode: http.StatusOK}) 63 | // all sessions should be invalidated 64 | res = clientToLogout.Do(t, "GET", []string{"_matrix", "client", "v3", "sync"}) 65 | must.MatchResponse(t, res, match.HTTPResponse{StatusCode: http.StatusUnauthorized}) 66 | res = verifyClientUser.Do(t, "GET", []string{"_matrix", "client", "v3", "sync"}) 67 | must.MatchResponse(t, res, match.HTTPResponse{StatusCode: http.StatusUnauthorized}) 68 | }) 69 | // sytest: Request to logout with invalid an access token is rejected 70 | t.Run("Request to logout with invalid an access token is rejected", func(t *testing.T) { 71 | _, clientToLogout := createSession(t, deployment, verifyClientUser.UserID, password) 72 | clientToLogout.AccessToken = "invalidAccessToken" 73 | res := clientToLogout.Do(t, "POST", []string{"_matrix", "client", "v3", "logout"}) 74 | must.MatchResponse(t, res, match.HTTPResponse{ 75 | StatusCode: http.StatusUnauthorized, 76 | JSON: []match.JSON{ 77 | match.JSONKeyEqual("errcode", "M_UNKNOWN_TOKEN"), 78 | }, 79 | }) 80 | }) 81 | // sytest: Request to logout without an access token is rejected 82 | t.Run("Request to logout without an access token is rejected", func(t *testing.T) { 83 | _, clientToLogout := createSession(t, deployment, verifyClientUser.UserID, password) 84 | clientToLogout.AccessToken = "" 85 | res := clientToLogout.Do(t, "POST", []string{"_matrix", "client", "v3", "logout"}) 86 | must.MatchResponse(t, res, match.HTTPResponse{ 87 | StatusCode: http.StatusUnauthorized, 88 | JSON: []match.JSON{ 89 | match.JSONKeyEqual("errcode", "M_MISSING_TOKEN"), 90 | }, 91 | }) 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_presence_test.go: -------------------------------------------------------------------------------- 1 | //go:build !dendrite_blacklist 2 | // +build !dendrite_blacklist 3 | 4 | // Rationale for being included in Dendrite's blacklist: https://github.com/matrix-org/complement/pull/104#discussion_r617646624 5 | 6 | package csapi_tests 7 | 8 | import ( 9 | "testing" 10 | 11 | "github.com/tidwall/gjson" 12 | 13 | "github.com/matrix-org/complement" 14 | "github.com/matrix-org/complement/b" 15 | "github.com/matrix-org/complement/client" 16 | "github.com/matrix-org/complement/helpers" 17 | "github.com/matrix-org/complement/match" 18 | "github.com/matrix-org/complement/must" 19 | "github.com/matrix-org/gomatrixserverlib/spec" 20 | ) 21 | 22 | func TestPresence(t *testing.T) { 23 | deployment := complement.Deploy(t, 1) 24 | defer deployment.Destroy(t) 25 | 26 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 27 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 28 | 29 | // to share presence alice and bob must be in a shared room 30 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 31 | bob.MustJoinRoom(t, roomID, []spec.ServerName{ 32 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 33 | }) 34 | 35 | // sytest: GET /presence/:user_id/status fetches initial status 36 | t.Run("GET /presence/:user_id/status fetches initial status", func(t *testing.T) { 37 | res := alice.Do(t, "GET", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}) 38 | must.MatchResponse(t, res, match.HTTPResponse{ 39 | JSON: []match.JSON{ 40 | match.JSONKeyPresent("presence"), 41 | }, 42 | }) 43 | }) 44 | // sytest: PUT /presence/:user_id/status updates my presence 45 | t.Run("PUT /presence/:user_id/status updates my presence", func(t *testing.T) { 46 | statusMsg := "Testing something" 47 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 48 | "status_msg": statusMsg, 49 | "presence": "online", 50 | }) 51 | res := alice.Do(t, "PUT", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}, reqBody) 52 | must.MatchResponse(t, res, match.HTTPResponse{ 53 | StatusCode: 200, 54 | }) 55 | res = alice.Do(t, "GET", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}) 56 | must.MatchResponse(t, res, match.HTTPResponse{ 57 | JSON: []match.JSON{ 58 | match.JSONKeyPresent("presence"), 59 | match.JSONKeyEqual("status_msg", statusMsg), 60 | }, 61 | }) 62 | }) 63 | // sytest: Presence can be set from sync 64 | t.Run("Presence can be set from sync", func(t *testing.T) { 65 | _, bobSinceToken := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 66 | 67 | alice.MustSync(t, client.SyncReq{TimeoutMillis: "0", SetPresence: "unavailable"}) 68 | 69 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSinceToken}, client.SyncPresenceHas(alice.UserID, b.Ptr("unavailable"))) 70 | }) 71 | // sytest: Presence changes are reported to local room members 72 | t.Run("Presence changes are reported to local room members", func(t *testing.T) { 73 | _, bobSinceToken := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 74 | 75 | statusMsg := "Update for room members" 76 | alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}, 77 | client.WithJSONBody(t, map[string]interface{}{ 78 | "status_msg": statusMsg, 79 | "presence": "online", 80 | }), 81 | ) 82 | 83 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSinceToken}, 84 | client.SyncPresenceHas(alice.UserID, b.Ptr("online"), func(ev gjson.Result) bool { 85 | return ev.Get("content.status_msg").Str == statusMsg 86 | }), 87 | ) 88 | }) 89 | // sytest: Presence changes to UNAVAILABLE are reported to local room members 90 | t.Run("Presence changes to UNAVAILABLE are reported to local room members", func(t *testing.T) { 91 | _, bobSinceToken := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 92 | 93 | alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}, 94 | client.WithJSONBody(t, map[string]interface{}{ 95 | "presence": "unavailable", 96 | }), 97 | ) 98 | 99 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSinceToken}, 100 | client.SyncPresenceHas(alice.UserID, b.Ptr("unavailable")), 101 | ) 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_profile_avatar_url_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/match" 10 | "github.com/matrix-org/complement/must" 11 | ) 12 | 13 | func TestProfileAvatarURL(t *testing.T) { 14 | deployment := complement.Deploy(t, 1) 15 | defer deployment.Destroy(t) 16 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 17 | authedClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 18 | avatarURL := "mxc://example.com/SEsfnsuifSDFSSEF" 19 | // sytest: PUT /profile/:user_id/avatar_url sets my avatar 20 | t.Run("PUT /profile/:user_id/avatar_url sets my avatar", func(t *testing.T) { 21 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 22 | "avatar_url": avatarURL, 23 | }) 24 | res := authedClient.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "profile", authedClient.UserID, "avatar_url"}, reqBody) 25 | 26 | must.MatchResponse(t, res, match.HTTPResponse{ 27 | StatusCode: 200, 28 | }) 29 | }) 30 | 31 | // sytest: GET /profile/:user_id/avatar_url publicly accessible 32 | t.Run("GET /profile/:user_id/avatar_url publicly accessible", func(t *testing.T) { 33 | res := unauthedClient.Do(t, "GET", []string{"_matrix", "client", "v3", "profile", authedClient.UserID, "avatar_url"}) 34 | 35 | must.MatchResponse(t, res, match.HTTPResponse{ 36 | StatusCode: 200, 37 | JSON: []match.JSON{ 38 | match.JSONKeyPresent("avatar_url"), 39 | match.JSONKeyEqual("avatar_url", avatarURL), 40 | }, 41 | }) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_profile_displayname_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/helpers" 8 | "github.com/matrix-org/complement/must" 9 | ) 10 | 11 | func TestProfileDisplayName(t *testing.T) { 12 | deployment := complement.Deploy(t, 1) 13 | defer deployment.Destroy(t) 14 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 15 | authedClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 16 | displayName := "my_display_name" 17 | // sytest: PUT /profile/:user_id/displayname sets my name 18 | t.Run("PUT /profile/:user_id/displayname sets my name", func(t *testing.T) { 19 | authedClient.MustSetDisplayName(t, displayName) 20 | }) 21 | // sytest: GET /profile/:user_id/displayname publicly accessible 22 | t.Run("GET /profile/:user_id/displayname publicly accessible", func(t *testing.T) { 23 | gotDisplayName := unauthedClient.MustGetDisplayName(t, authedClient.UserID) 24 | must.Equal(t, gotDisplayName, displayName, "display name mismatch") 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_request_encoding_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "encoding/json" 7 | 8 | "github.com/matrix-org/complement" 9 | "github.com/matrix-org/complement/client" 10 | "github.com/matrix-org/complement/match" 11 | "github.com/matrix-org/complement/must" 12 | ) 13 | 14 | func TestRequestEncodingFails(t *testing.T) { 15 | deployment := complement.Deploy(t, 1) 16 | defer deployment.Destroy(t) 17 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 18 | testString := `{ "test":"a` + "\x81" + `" }` 19 | // sytest: POST rejects invalid utf-8 in JSON 20 | t.Run("POST rejects invalid utf-8 in JSON", func(t *testing.T) { 21 | res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(testString))) 22 | must.MatchResponse(t, res, match.HTTPResponse{ 23 | StatusCode: 400, 24 | JSON: []match.JSON{ 25 | match.JSONKeyEqual("errcode", "M_NOT_JSON"), 26 | }, 27 | }) 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_room_receipts_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/b" 8 | "github.com/matrix-org/complement/client" 9 | "github.com/matrix-org/complement/helpers" 10 | "github.com/tidwall/gjson" 11 | ) 12 | 13 | // tests/10apidoc/37room-receipts.pl 14 | 15 | func createRoomForReadReceipts(t *testing.T, c *client.CSAPI) (string, string) { 16 | roomID := c.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 17 | 18 | c.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(c.UserID, roomID)) 19 | 20 | eventID := c.SendEventSynced(t, roomID, b.Event{ 21 | Type: "m.room.message", 22 | Content: map[string]interface{}{ 23 | "msgtype": "m.text", 24 | "body": "Hello world!", 25 | }, 26 | }) 27 | 28 | return roomID, eventID 29 | } 30 | 31 | func syncHasReadReceipt(roomID, userID, eventID string) client.SyncCheckOpt { 32 | return client.SyncEphemeralHas(roomID, func(result gjson.Result) bool { 33 | return result.Get("type").Str == "m.receipt" && 34 | result.Get("content").Get(eventID).Get(`m\.read`).Get(userID).Exists() 35 | }) 36 | } 37 | 38 | // sytest: POST /rooms/:room_id/receipt can create receipts 39 | func TestRoomReceipts(t *testing.T) { 40 | deployment := complement.Deploy(t, 1) 41 | defer deployment.Destroy(t) 42 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 43 | roomID, eventID := createRoomForReadReceipts(t, alice) 44 | 45 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "receipt", "m.read", eventID}, client.WithJSONBody(t, struct{}{})) 46 | 47 | // Make sure the read receipt shows up in sync. 48 | alice.MustSyncUntil(t, client.SyncReq{}, syncHasReadReceipt(roomID, alice.UserID, eventID)) 49 | } 50 | 51 | // sytest: POST /rooms/:room_id/read_markers can create read marker 52 | func TestRoomReadMarkers(t *testing.T) { 53 | deployment := complement.Deploy(t, 1) 54 | defer deployment.Destroy(t) 55 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 56 | roomID, eventID := createRoomForReadReceipts(t, alice) 57 | 58 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 59 | "m.fully_read": eventID, 60 | "m.read": eventID, 61 | }) 62 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "read_markers"}, reqBody) 63 | 64 | // Make sure the read receipt shows up in sync. 65 | alice.MustSyncUntil(t, client.SyncReq{}, syncHasReadReceipt(roomID, alice.UserID, eventID)) 66 | 67 | // Make sure that the fully_read receipt shows up in account data via sync. 68 | // Use the same token as above to replay the syncs. 69 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncRoomAccountDataHas(roomID, func(result gjson.Result) bool { 70 | return result.Get("type").Str == "m.fully_read" && 71 | result.Get("content.event_id").Str == eventID 72 | })) 73 | } 74 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_server_capabilities_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/matrix-org/complement" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/match" 10 | "github.com/matrix-org/complement/must" 11 | ) 12 | 13 | func TestServerCapabilities(t *testing.T) { 14 | deployment := complement.Deploy(t, 1) 15 | defer deployment.Destroy(t) 16 | 17 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 18 | authedClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 19 | 20 | // sytest: GET /capabilities is present and well formed for registered user 21 | data := authedClient.GetCapabilities(t) 22 | 23 | must.MatchJSONBytes( 24 | t, 25 | data, 26 | match.JSONKeyPresent(`capabilities.m\.room_versions`), 27 | match.JSONKeyPresent(`capabilities.m\.change_password`), 28 | ) 29 | 30 | // sytest: GET /v3/capabilities is not public 31 | res := unauthedClient.Do(t, "GET", []string{"_matrix", "client", "v3", "capabilities"}) 32 | must.MatchResponse(t, res, match.HTTPResponse{ 33 | StatusCode: http.StatusUnauthorized, 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /tests/csapi/apidoc_version_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/tidwall/gjson" 9 | 10 | "github.com/matrix-org/complement" 11 | "github.com/matrix-org/complement/match" 12 | "github.com/matrix-org/complement/must" 13 | ) 14 | 15 | // https://spec.matrix.org/v1.1/#specification-versions 16 | // altered to limit to X = 1+, as v0 has never existed 17 | const GlobalVersionRegex = `v[1-9]\d*\.\d+(?:-\S+)?` 18 | 19 | // https://github.com/matrix-org/matrix-doc/blob/client_server/r0.6.1/specification/index.rst#specification-versions 20 | // altered to limit to X = 0 (r0), as r1+ will never exist. 21 | const r0Regex = `r0\.\d+\.\d+` 22 | 23 | func TestVersionStructure(t *testing.T) { 24 | deployment := complement.Deploy(t, 1) 25 | defer deployment.Destroy(t) 26 | 27 | client := deployment.UnauthenticatedClient(t, "hs1") 28 | 29 | // sytest: Version responds 200 OK with valid structure 30 | t.Run("Version responds 200 OK with valid structure", func(t *testing.T) { 31 | res := client.MustDo(t, "GET", []string{"_matrix", "client", "versions"}) 32 | 33 | // Matches; 34 | // - r0.?.? 35 | // where ? is any single digit 36 | // - v1^.*(-#) 37 | // where 1^ is 1 through 9 for the first digit, then any digit thereafter, 38 | // and * is any single or multiple of digits 39 | // optionally with dash-separated metadata: (-#) 40 | versionRegex, _ := regexp.Compile("^(" + r0Regex + "|" + GlobalVersionRegex + ")$") 41 | 42 | must.MatchResponse(t, res, match.HTTPResponse{ 43 | JSON: []match.JSON{ 44 | match.JSONKeyPresent("versions"), 45 | match.JSONArrayEach("versions", func(val gjson.Result) error { 46 | if val.Type != gjson.String { 47 | return fmt.Errorf("'versions' value is not a string: %s", val.Raw) 48 | } 49 | if !versionRegex.MatchString(val.Str) { 50 | return fmt.Errorf("value in 'versions' array did not match version regex: %s", val.Str) 51 | } 52 | return nil 53 | }), 54 | // Check when unstable_features is present if it's an object 55 | func(body gjson.Result) error { 56 | res := body.Get("unstable_features") 57 | if !res.Exists() { 58 | return nil 59 | } 60 | if !res.IsObject() { 61 | return fmt.Errorf("unstable_features was present, and wasn't an object") 62 | } 63 | for k, v := range res.Map() { 64 | // gjson doesn't have a "boolean" type to check against 65 | if v.Type != gjson.True && v.Type != gjson.False { 66 | return fmt.Errorf("value for key 'unstable_features.%s' is of the wrong type, got %s want boolean", k, v.Type) 67 | } 68 | } 69 | return nil 70 | }, 71 | }, 72 | }) 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /tests/csapi/ignored_users_test.go: -------------------------------------------------------------------------------- 1 | //go:build !dendrite_blacklist 2 | // +build !dendrite_blacklist 3 | 4 | // Rationale for being included in Dendrite's blacklist: https://github.com/matrix-org/dendrite/issues/600 5 | package csapi_tests 6 | 7 | import ( 8 | "net/url" 9 | "testing" 10 | 11 | "github.com/tidwall/gjson" 12 | 13 | "github.com/matrix-org/complement" 14 | "github.com/matrix-org/complement/client" 15 | "github.com/matrix-org/complement/helpers" 16 | "github.com/matrix-org/complement/match" 17 | "github.com/matrix-org/complement/must" 18 | ) 19 | 20 | // The Spec says here 21 | // 22 | // https://spec.matrix.org/v1.1/client-server-api/#server-behaviour-13 23 | // 24 | // that 25 | // > Servers must not send room invites from ignored users to clients. 26 | // 27 | // This is a regression test for 28 | // https://github.com/matrix-org/synapse/issues/11506 29 | // to ensure that Synapse complies with this part of the spec. 30 | func TestInviteFromIgnoredUsersDoesNotAppearInSync(t *testing.T) { 31 | deployment := complement.Deploy(t, 1) 32 | defer deployment.Destroy(t) 33 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) 34 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"}) 35 | chris := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "chris"}) 36 | 37 | // Alice creates a room for herself. 38 | publicRoom := alice.MustCreateRoom(t, map[string]interface{}{ 39 | "preset": "public_chat", 40 | }) 41 | 42 | // Alice waits to see the join event. 43 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, publicRoom)) 44 | 45 | // Alice ignores Bob. 46 | alice.MustSetGlobalAccountData(t, "m.ignored_user_list", map[string]interface{}{ 47 | "ignored_users": map[string]interface{}{ 48 | bob.UserID: map[string]interface{}{}, 49 | }, 50 | }) 51 | 52 | // Alice waits to see that the ignore was successful. 53 | sinceJoinedAndIgnored := alice.MustSyncUntil(t, client.SyncReq{}, client.SyncGlobalAccountDataHas( 54 | func(ev gjson.Result) bool { 55 | t.Logf(ev.Raw + "\n") 56 | return ev.Get("type").Str == "m.ignored_user_list" && 57 | ev.Get("content.ignored_users."+client.GjsonEscape(bob.UserID)).Exists() 58 | }, 59 | )) 60 | 61 | // Bob invites Alice to a private room. 62 | bobRoom := bob.MustCreateRoom(t, map[string]interface{}{ 63 | "preset": "private_chat", 64 | "invite": []string{alice.UserID}, 65 | }) 66 | 67 | // So does Chris. 68 | chrisRoom := chris.MustCreateRoom(t, map[string]interface{}{ 69 | "preset": "private_chat", 70 | "invite": []string{alice.UserID}, 71 | }) 72 | 73 | // Alice waits until she's seen Chris's invite. 74 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(alice.UserID, chrisRoom)) 75 | 76 | // We re-request the sync with a `since` token. We should see Chris's invite, but not Bob's. 77 | queryParams := url.Values{ 78 | "since": {sinceJoinedAndIgnored}, 79 | "timeout": {"0"}, 80 | } 81 | // Note: SyncUntil only runs its callback on array elements. I want to investigate an object. 82 | // So let's make the HTTP request more directly. 83 | response := alice.MustDo( 84 | t, 85 | "GET", 86 | []string{"_matrix", "client", "v3", "sync"}, 87 | client.WithQueries(queryParams), 88 | ) 89 | bobRoomPath := "rooms.invite." + client.GjsonEscape(bobRoom) 90 | chrisRoomPath := "rooms.invite." + client.GjsonEscape(chrisRoom) 91 | must.MatchResponse(t, response, match.HTTPResponse{ 92 | JSON: []match.JSON{ 93 | match.JSONKeyMissing(bobRoomPath), 94 | match.JSONKeyPresent(chrisRoomPath), 95 | }, 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /tests/csapi/keychanges_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "testing" 8 | 9 | "github.com/tidwall/gjson" 10 | 11 | "github.com/matrix-org/complement" 12 | "github.com/matrix-org/complement/client" 13 | "github.com/matrix-org/complement/helpers" 14 | "github.com/matrix-org/complement/match" 15 | "github.com/matrix-org/complement/must" 16 | "github.com/matrix-org/gomatrixserverlib/spec" 17 | ) 18 | 19 | func TestKeyChangesLocal(t *testing.T) { 20 | deployment := complement.Deploy(t, 1) 21 | defer deployment.Destroy(t) 22 | 23 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 24 | password := "$uperSecretPassword" 25 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 26 | LocalpartSuffix: "bob", 27 | Password: password, 28 | }) 29 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 30 | 31 | t.Run("New login should create a device_lists.changed entry", func(t *testing.T) { 32 | bobDeviceKeys, bobOTKs := bob.MustGenerateOneTimeKeys(t, 1) 33 | bob.MustUploadKeys(t, bobDeviceKeys, bobOTKs) 34 | 35 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 36 | bob.MustJoinRoom(t, roomID, []spec.ServerName{}) 37 | nextBatch1 := alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 38 | 39 | reqBody := client.WithJSONBody(t, map[string]interface{}{ 40 | "identifier": map[string]interface{}{ 41 | "type": "m.id.user", 42 | "user": bob.UserID, 43 | }, 44 | "type": "m.login.password", 45 | "password": password, 46 | }) 47 | // Create a new device by logging in 48 | res := unauthedClient.MustDo(t, "POST", []string{"_matrix", "client", "r0", "login"}, reqBody) 49 | loginResp := must.ParseJSON(t, res.Body) 50 | unauthedClient.AccessToken = must.GetJSONFieldStr(t, loginResp, "access_token") 51 | unauthedClient.DeviceID = must.GetJSONFieldStr(t, loginResp, "device_id") 52 | unauthedClient.UserID = must.GetJSONFieldStr(t, loginResp, "user_id") 53 | unauthedKeys, unauthedOTKs := unauthedClient.MustGenerateOneTimeKeys(t, 1) 54 | unauthedClient.MustUploadKeys(t, unauthedKeys, unauthedOTKs) 55 | 56 | // Alice should now see a device list changed entry for Bob 57 | nextBatch := alice.MustSyncUntil(t, client.SyncReq{Since: nextBatch1}, func(userID string, syncResp gjson.Result) error { 58 | deviceListsChanged := syncResp.Get("device_lists.changed") 59 | if !deviceListsChanged.IsArray() { 60 | return fmt.Errorf("no device_lists.changed entry found: %+v", syncResp.Raw) 61 | } 62 | for _, userID := range deviceListsChanged.Array() { 63 | if userID.String() == bob.UserID { 64 | return nil 65 | } 66 | } 67 | return fmt.Errorf("no device_lists.changed entry found for %s", bob.UserID) 68 | }) 69 | // Verify on /keys/changes that Bob has changes 70 | queryParams := url.Values{} 71 | queryParams.Set("from", nextBatch1) 72 | queryParams.Set("to", nextBatch) 73 | resp := alice.MustDo(t, "GET", []string{"_matrix", "client", "v3", "keys", "changes"}, client.WithQueries(queryParams)) 74 | must.MatchResponse(t, resp, match.HTTPResponse{ 75 | StatusCode: http.StatusOK, 76 | JSON: []match.JSON{ 77 | match.JSONKeyEqual("changed.0", bob.UserID), // there should only be one change, so access it directly 78 | }, 79 | }) 80 | 81 | // Get Bobs keys, there should be two 82 | queryKeys := client.WithJSONBody(t, map[string]interface{}{ 83 | "device_keys": map[string][]string{ 84 | bob.UserID: {}, 85 | }, 86 | }) 87 | resp = alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "query"}, queryKeys) 88 | keyCount := 0 89 | must.MatchResponse(t, resp, match.HTTPResponse{ 90 | StatusCode: http.StatusOK, 91 | JSON: []match.JSON{ 92 | match.JSONMapEach("device_keys."+bob.UserID, func(k, v gjson.Result) error { 93 | keyCount++ 94 | return nil 95 | }), 96 | }, 97 | }) 98 | wantKeyCount := 2 99 | if keyCount != wantKeyCount { 100 | t.Fatalf("unexpected key count: got %d, want %d", keyCount, wantKeyCount) 101 | } 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /tests/csapi/main_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "csapi") 11 | } 12 | -------------------------------------------------------------------------------- /tests/csapi/media_async_uploads_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/internal/data" 13 | "github.com/matrix-org/complement/match" 14 | "github.com/matrix-org/complement/must" 15 | "github.com/matrix-org/complement/runtime" 16 | ) 17 | 18 | func TestAsyncUpload(t *testing.T) { 19 | runtime.SkipIf(t, runtime.Dendrite) // Dendrite doesn't support async uploads 20 | 21 | deployment := complement.Deploy(t, 1) 22 | defer deployment.Destroy(t) 23 | 24 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 25 | 26 | var mxcURI, mediaID string 27 | t.Run("Create media", func(t *testing.T) { 28 | mxcURI = alice.CreateMedia(t) 29 | parts := strings.Split(mxcURI, "/") 30 | mediaID = parts[len(parts)-1] 31 | }) 32 | 33 | origin, mediaID := client.SplitMxc(mxcURI) 34 | 35 | t.Run("Not yet uploaded", func(t *testing.T) { 36 | // Check that the media is not yet uploaded 37 | res := alice.Do(t, "GET", []string{"_matrix", "media", "v3", "download", origin, mediaID}) 38 | must.MatchResponse(t, res, match.HTTPResponse{ 39 | StatusCode: http.StatusGatewayTimeout, 40 | JSON: []match.JSON{ 41 | match.JSONKeyEqual("errcode", "M_NOT_YET_UPLOADED"), 42 | match.JSONKeyEqual("error", "Media has not been uploaded yet"), 43 | }, 44 | }) 45 | }) 46 | 47 | wantContentType := "image/png" 48 | 49 | t.Run("Upload media", func(t *testing.T) { 50 | alice.UploadMediaAsync(t, origin, mediaID, data.MatrixPng, "test.png", wantContentType) 51 | }) 52 | 53 | t.Run("Cannot upload to a media ID that has already been uploaded to", func(t *testing.T) { 54 | res := alice.Do(t, "PUT", []string{"_matrix", "media", "v3", "upload", origin, mediaID}) 55 | must.MatchResponse(t, res, match.HTTPResponse{ 56 | StatusCode: http.StatusConflict, 57 | JSON: []match.JSON{ 58 | match.JSONKeyEqual("errcode", "M_CANNOT_OVERWRITE_MEDIA"), 59 | match.JSONKeyEqual("error", "Media ID already has content"), 60 | }, 61 | }) 62 | }) 63 | 64 | t.Run("Download media", func(t *testing.T) { 65 | content, contentType := alice.DownloadContentAuthenticated(t, mxcURI) 66 | if !bytes.Equal(data.MatrixPng, content) { 67 | t.Fatalf("uploaded and downloaded content doesn't match: want %v\ngot\n%v", data.MatrixPng, content) 68 | } 69 | if contentType != wantContentType { 70 | t.Fatalf("expected contentType to be %s, got %s", wantContentType, contentType) 71 | } 72 | }) 73 | 74 | t.Run("Download media over _matrix/client/v1/media/download", func(t *testing.T) { 75 | content, contentType := alice.DownloadContentAuthenticated(t, mxcURI) 76 | if !bytes.Equal(data.MatrixPng, content) { 77 | t.Fatalf("uploaded and downloaded content doesn't match: want %v\ngot\n%v", data.MatrixPng, content) 78 | } 79 | if contentType != wantContentType { 80 | t.Fatalf("expected contentType to be %s, got %s", wantContentType, contentType) 81 | } 82 | }) 83 | } 84 | -------------------------------------------------------------------------------- /tests/csapi/media_misc_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/tidwall/gjson" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/b" 11 | "github.com/matrix-org/complement/client" 12 | "github.com/matrix-org/complement/helpers" 13 | "github.com/matrix-org/complement/internal/data" 14 | "github.com/matrix-org/complement/match" 15 | "github.com/matrix-org/complement/must" 16 | "github.com/matrix-org/complement/runtime" 17 | ) 18 | 19 | // sytest: Can send image in room message 20 | // sytest: Can fetch images in room 21 | func TestRoomImageRoundtrip(t *testing.T) { 22 | runtime.SkipIf(t, runtime.Dendrite) // FIXME: https://github.com/matrix-org/dendrite/issues/1303 23 | 24 | deployment := complement.Deploy(t, 1) 25 | defer deployment.Destroy(t) 26 | 27 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 28 | 29 | mxcUri := alice.UploadContent(t, data.MatrixPng, "test.png", "image/png") 30 | 31 | roomId := alice.MustCreateRoom(t, map[string]interface{}{}) 32 | 33 | alice.SendEventSynced(t, roomId, b.Event{ 34 | Type: "m.room.message", 35 | Content: map[string]interface{}{ 36 | "msgtype": "m.text", 37 | "body": "test", 38 | }, 39 | }) 40 | 41 | alice.SendEventSynced(t, roomId, b.Event{ 42 | Type: "m.room.message", 43 | Content: map[string]interface{}{ 44 | "msgtype": "m.file", 45 | "body": "test.png", 46 | "url": mxcUri, 47 | }, 48 | }) 49 | 50 | messages := alice.MustDo(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomId, "messages"}, client.WithQueries(url.Values{ 51 | "filter": []string{`{"contains_url":true}`}, 52 | "dir": []string{"b"}, 53 | })) 54 | 55 | must.MatchResponse(t, messages, match.HTTPResponse{ 56 | JSON: []match.JSON{ 57 | match.JSONKeyTypeEqual("start", gjson.String), 58 | match.JSONKeyTypeEqual("end", gjson.String), 59 | match.JSONKeyArrayOfSize("chunk", 1), 60 | }, 61 | }) 62 | } 63 | 64 | // sytest: Can read configuration endpoint 65 | func TestMediaConfig(t *testing.T) { 66 | deployment := complement.Deploy(t, 1) 67 | defer deployment.Destroy(t) 68 | 69 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 70 | 71 | res := alice.MustDo(t, "GET", []string{"_matrix", "media", "v3", "config"}) 72 | 73 | must.MatchResponse(t, res, match.HTTPResponse{ 74 | JSON: []match.JSON{ 75 | match.JSONKeyTypeEqual(client.GjsonEscape("m.upload.size"), gjson.Number), 76 | }, 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /tests/csapi/membership_on_events_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement/b" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/runtime" 9 | "github.com/matrix-org/gomatrixserverlib/spec" 10 | 11 | "github.com/matrix-org/complement" 12 | "github.com/matrix-org/complement/helpers" 13 | ) 14 | 15 | // Membership information on events served to clients, as specified in MSC4115. 16 | // 17 | // Alice sends one message before Bob joins, then one after. Bob reads both messages, and checks the membership state 18 | // on each. 19 | func TestMembershipOnEvents(t *testing.T) { 20 | runtime.SkipIf(t, runtime.Dendrite) // not yet implemented 21 | 22 | deployment := complement.Deploy(t, 1) 23 | defer deployment.Destroy(t) 24 | 25 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 26 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 27 | 28 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 29 | preJoinEventID := alice.SendEventSynced(t, roomID, b.Event{Type: "m.room.message", 30 | Content: map[string]interface{}{ 31 | "msgtype": "m.text", 32 | "body": "prejoin", 33 | }}) 34 | bob.MustJoinRoom(t, roomID, []spec.ServerName{ 35 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 36 | }) 37 | postJoinEventID := alice.SendEventSynced(t, roomID, b.Event{Type: "m.room.message", 38 | Content: map[string]interface{}{ 39 | "msgtype": "m.text", 40 | "body": "postjoin", 41 | }}) 42 | 43 | // Now Bob syncs, to get the messages 44 | syncResult, _ := bob.MustSync(t, client.SyncReq{}) 45 | if err := client.SyncTimelineHasEventID(roomID, preJoinEventID)(alice.UserID, syncResult); err != nil { 46 | t.Fatalf("Sync response lacks prejoin event: %s", err) 47 | } 48 | if err := client.SyncTimelineHasEventID(roomID, postJoinEventID)(alice.UserID, syncResult); err != nil { 49 | t.Fatalf("Sync response lacks prejoin event: %s", err) 50 | } 51 | 52 | // ... and we check the membership value for each event. Should be "leave" for each event until the join. 53 | haveSeenJoin := false 54 | roomSyncResult := syncResult.Get("rooms.join." + client.GjsonEscape(roomID)) 55 | for _, ev := range roomSyncResult.Get("timeline.events").Array() { 56 | if ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == bob.UserID { 57 | haveSeenJoin = true 58 | } 59 | membership := ev.Get("unsigned.membership").Str 60 | expectedMembership := "leave" 61 | if haveSeenJoin { 62 | expectedMembership = "join" 63 | } 64 | if membership != expectedMembership { 65 | t.Errorf("Incorrect membership for event %s; got %s, want %s", ev, membership, expectedMembership) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/csapi/room_ban_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/b" 8 | "github.com/matrix-org/complement/client" 9 | "github.com/matrix-org/complement/helpers" 10 | "github.com/matrix-org/complement/match" 11 | "github.com/matrix-org/complement/must" 12 | ) 13 | 14 | // This is technically a tad different from the sytest, in that it doesnt try to ban a @random_dude:test, 15 | // but this will actually validate against a present user in the room. 16 | // sytest: Non-present room members cannot ban others 17 | func TestNotPresentUserCannotBanOthers(t *testing.T) { 18 | deployment := complement.Deploy(t, 1) 19 | defer deployment.Destroy(t) 20 | 21 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 22 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 23 | charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 24 | 25 | roomID := alice.MustCreateRoom(t, map[string]interface{}{ 26 | "preset": "public_chat", 27 | }) 28 | 29 | bob.MustJoinRoom(t, roomID, nil) 30 | 31 | alice.SendEventSynced(t, roomID, b.Event{ 32 | Type: "m.room.power_levels", 33 | StateKey: b.Ptr(""), 34 | Content: map[string]interface{}{ 35 | "users": map[string]interface{}{ 36 | charlie.UserID: 100, 37 | }, 38 | }, 39 | }) 40 | 41 | bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 42 | 43 | res := charlie.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "ban"}, client.WithJSONBody(t, map[string]interface{}{ 44 | "user_id": bob.UserID, 45 | "reason": "testing", 46 | })) 47 | 48 | must.MatchResponse(t, res, match.HTTPResponse{ 49 | StatusCode: 403, 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /tests/csapi/room_kick_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/match" 10 | "github.com/matrix-org/complement/must" 11 | ) 12 | 13 | // sytest: Users cannot kick users from a room they are not in 14 | func TestCannotKickNonPresentUser(t *testing.T) { 15 | deployment := complement.Deploy(t, 1) 16 | defer deployment.Destroy(t) 17 | 18 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 19 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 20 | 21 | roomID := alice.MustCreateRoom(t, map[string]interface{}{ 22 | "preset": "public_chat", 23 | }) 24 | 25 | resp := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "kick"}, 26 | client.WithJSONBody(t, map[string]interface{}{ 27 | "user_id": bob.UserID, 28 | "reason": "testing", 29 | }), 30 | ) 31 | 32 | must.MatchResponse(t, resp, match.HTTPResponse{ 33 | StatusCode: 403, 34 | }) 35 | } 36 | 37 | // sytest: Users cannot kick users who have already left a room 38 | func TestCannotKickLeftUser(t *testing.T) { 39 | deployment := complement.Deploy(t, 1) 40 | defer deployment.Destroy(t) 41 | 42 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 43 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 44 | 45 | roomID := alice.MustCreateRoom(t, map[string]interface{}{ 46 | "preset": "public_chat", 47 | }) 48 | 49 | bob.MustJoinRoom(t, roomID, nil) 50 | 51 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 52 | 53 | bob.MustLeaveRoom(t, roomID) 54 | 55 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncLeftFrom(bob.UserID, roomID)) 56 | 57 | resp := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "kick"}, 58 | client.WithJSONBody(t, map[string]interface{}{ 59 | "user_id": bob.UserID, 60 | "reason": "testing", 61 | }), 62 | ) 63 | 64 | must.MatchResponse(t, resp, match.HTTPResponse{ 65 | StatusCode: 403, 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /tests/csapi/room_profile_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tidwall/gjson" 7 | 8 | "github.com/matrix-org/complement" 9 | "github.com/matrix-org/complement/client" 10 | "github.com/matrix-org/complement/helpers" 11 | ) 12 | 13 | func TestAvatarUrlUpdate(t *testing.T) { 14 | testProfileFieldUpdate(t, "avatar_url") 15 | } 16 | 17 | func TestDisplayNameUpdate(t *testing.T) { 18 | testProfileFieldUpdate(t, "displayname") 19 | } 20 | 21 | // sytest: $datum updates affect room member events 22 | func testProfileFieldUpdate(t *testing.T, field string) { 23 | deployment := complement.Deploy(t, 1) 24 | defer deployment.Destroy(t) 25 | 26 | const bogusData = "LemurLover" 27 | 28 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 29 | 30 | roomID := alice.MustCreateRoom(t, map[string]interface{}{ 31 | "preset": "public_chat", 32 | }) 33 | 34 | sinceToken := alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) 35 | 36 | alice.MustDo( 37 | t, 38 | "PUT", 39 | []string{"_matrix", "client", "v3", "profile", alice.UserID, field}, 40 | client.WithJSONBody(t, map[string]interface{}{ 41 | field: bogusData, 42 | }), 43 | ) 44 | 45 | alice.MustSyncUntil(t, client.SyncReq{Since: sinceToken}, client.SyncJoinedTo(alice.UserID, roomID, func(result gjson.Result) bool { 46 | return result.Get("content."+field).Str == bogusData 47 | })) 48 | } 49 | -------------------------------------------------------------------------------- /tests/csapi/room_threads_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/tidwall/gjson" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | "github.com/matrix-org/complement/runtime" 15 | ) 16 | 17 | // A tuple of thread ID + latest event ID to match against. 18 | func threadKey(threadID, latestEventID string) string { 19 | return threadID + "|" + latestEventID 20 | } 21 | 22 | func checkResults(t *testing.T, body []byte, expected []string) { 23 | t.Helper() 24 | 25 | values := gjson.GetBytes(body, "chunk") 26 | var result []string 27 | for _, v := range values.Array() { 28 | result = append(result, threadKey(v.Get("event_id").Str, v.Get("unsigned.m\\.relations.m\\.thread.latest_event.event_id").Str)) 29 | } 30 | must.HaveInOrder(t, result, expected) 31 | } 32 | 33 | // Test the /threads endpoint. 34 | func TestThreadsEndpoint(t *testing.T) { 35 | runtime.SkipIf(t, runtime.Dendrite) // not supported 36 | deployment := complement.Deploy(t, 1) 37 | defer deployment.Destroy(t) 38 | 39 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 40 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 41 | _, token := alice.MustSync(t, client.SyncReq{}) 42 | 43 | // Create 2 threads in the room. 44 | res := alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "rooms", roomID, "send", "m.room.message", "txn-1"}, client.WithJSONBody(t, map[string]interface{}{ 45 | "msgtype": "m.text", 46 | "body": "Thread 1 Root", 47 | })) 48 | threadID1 := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "event_id") 49 | 50 | res = alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "rooms", roomID, "send", "m.room.message", "txn-2"}, client.WithJSONBody(t, map[string]interface{}{ 51 | "msgtype": "m.text", 52 | "body": "Thread 2 Root", 53 | })) 54 | threadID2 := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "event_id") 55 | 56 | // Add threaded replies. 57 | res = alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "rooms", roomID, "send", "m.room.message", "txn-3"}, client.WithJSONBody(t, map[string]interface{}{ 58 | "msgtype": "m.text", 59 | "body": "Thread 1 Reply", 60 | "m.relates_to": map[string]interface{}{ 61 | "event_id": threadID1, 62 | "rel_type": "m.thread", 63 | }, 64 | })) 65 | replyID1 := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "event_id") 66 | 67 | res = alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "rooms", roomID, "send", "m.room.message", "txn-4"}, client.WithJSONBody(t, map[string]interface{}{ 68 | "msgtype": "m.text", 69 | "body": "Thread 2 Reply", 70 | "m.relates_to": map[string]interface{}{ 71 | "event_id": threadID2, 72 | "rel_type": "m.thread", 73 | }, 74 | })) 75 | replyID2 := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "event_id") 76 | 77 | // sync until the server has processed it 78 | alice.MustSyncUntil(t, client.SyncReq{Since: token}, client.SyncTimelineHas(roomID, func(r gjson.Result) bool { 79 | return r.Get("event_id").Str == replyID2 80 | })) 81 | 82 | // Request the threads. 83 | res = alice.MustDo(t, "GET", []string{"_matrix", "client", "v1", "rooms", roomID, "threads"}) 84 | body := must.MatchResponse(t, res, match.HTTPResponse{ 85 | StatusCode: http.StatusOK, 86 | }) 87 | 88 | // Ensure the threads were properly ordered. 89 | checkResults(t, body, []string{threadKey(threadID2, replyID2), threadKey(threadID1, replyID1)}) 90 | 91 | // Update thread 1 and ensure it gets updated. 92 | res = alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "rooms", roomID, "send", "m.room.message", "txn-5"}, client.WithJSONBody(t, map[string]interface{}{ 93 | "msgtype": "m.text", 94 | "body": "Thread 1 Reply 2", 95 | "m.relates_to": map[string]interface{}{ 96 | "event_id": threadID1, 97 | "rel_type": "m.thread", 98 | }, 99 | })) 100 | replyID3 := client.GetJSONFieldStr(t, client.ParseJSON(t, res), "event_id") 101 | 102 | // sync until the server has processed it 103 | alice.MustSyncUntil(t, client.SyncReq{Since: token}, client.SyncTimelineHas(roomID, func(r gjson.Result) bool { 104 | return r.Get("event_id").Str == replyID3 105 | })) 106 | 107 | res = alice.MustDo(t, "GET", []string{"_matrix", "client", "v1", "rooms", roomID, "threads"}) 108 | body = must.MatchResponse(t, res, match.HTTPResponse{ 109 | StatusCode: http.StatusOK, 110 | }) 111 | 112 | // Ensure the threads were properly ordered. 113 | checkResults(t, body, []string{threadKey(threadID1, replyID3), threadKey(threadID2, replyID2)}) 114 | } 115 | -------------------------------------------------------------------------------- /tests/csapi/room_typing_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | ) 10 | 11 | // sytest: PUT /rooms/:room_id/typing/:user_id sets typing notification 12 | func TestTyping(t *testing.T) { 13 | deployment := complement.Deploy(t, 1) 14 | defer deployment.Destroy(t) 15 | 16 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 17 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 18 | 19 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 20 | 21 | bob.MustJoinRoom(t, roomID, nil) 22 | 23 | token := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 24 | alice.SendTyping(t, roomID, true, 10000) 25 | 26 | // sytest: Typing notification sent to local room members 27 | t.Run("Typing notification sent to local room members", func(t *testing.T) { 28 | bob.MustSyncUntil(t, client.SyncReq{Since: token}, client.SyncUsersTyping(roomID, []string{alice.UserID})) 29 | }) 30 | 31 | // sytest: Typing can be explicitly stopped 32 | t.Run("Typing can be explicitly stopped", func(t *testing.T) { 33 | alice.SendTyping(t, roomID, false, 0) 34 | bob.MustSyncUntil(t, client.SyncReq{Since: token}, client.SyncUsersTyping(roomID, []string{})) 35 | }) 36 | } 37 | 38 | // sytest: Typing notifications don't leak 39 | func TestLeakyTyping(t *testing.T) { 40 | deployment := complement.Deploy(t, 1) 41 | defer deployment.Destroy(t) 42 | 43 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 44 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 45 | charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 46 | LocalpartSuffix: "charlie", 47 | Password: "charliepassword", 48 | }) 49 | 50 | // Alice creates a room. Bob joins it. 51 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 52 | bob.MustJoinRoom(t, roomID, nil) 53 | 54 | bobToken := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 55 | 56 | _, charlieToken := charlie.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 57 | 58 | // Alice types in that room. Bob should see her typing. 59 | alice.SendTyping(t, roomID, true, 10000) 60 | 61 | bob.MustSyncUntil(t, client.SyncReq{Since: bobToken}, client.SyncUsersTyping(roomID, []string{alice.UserID})) 62 | 63 | // Charlie is not in the room, so should not see Alice typing, or anything from that room at all. 64 | res, _ := charlie.MustSync(t, client.SyncReq{TimeoutMillis: "1000", Since: charlieToken}) 65 | if res.Get("rooms.join." + client.GjsonEscape(roomID)).Exists() { 66 | t.Fatalf("Received unexpected room: %s", res.Raw) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/csapi/rooms_members_local_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/runtime" 10 | "github.com/matrix-org/gomatrixserverlib/spec" 11 | ) 12 | 13 | func TestMembersLocal(t *testing.T) { 14 | deployment := complement.Deploy(t, 1) 15 | defer deployment.Destroy(t) 16 | 17 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 18 | // Here we don't use the BlueprintOneToOneRoom because else Bob would be able to see Alice's presence changes through 19 | // that pre-existing one-on-one DM room. So we exclude that here. 20 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 21 | LocalpartSuffix: "bob", 22 | Password: "bobspassword", 23 | }) 24 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 25 | 26 | bob.MustDo( 27 | t, "PUT", []string{"_matrix", "client", "v3", "presence", bob.UserID, "status"}, 28 | client.WithJSONBody(t, map[string]interface{}{ 29 | "presence": "online", 30 | }), 31 | ) 32 | 33 | _, incrementalSyncTokenBeforeBobJoinsRoom := alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 34 | bob.MustJoinRoom(t, roomID, []spec.ServerName{}) 35 | 36 | t.Run("Parallel", func(t *testing.T) { 37 | // sytest: New room members see their own join event 38 | t.Run("New room members see their own join event", func(t *testing.T) { 39 | t.Parallel() 40 | // SyncJoinedTo already checks everything we need to know 41 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) 42 | }) 43 | 44 | // sytest: Existing members see new members' join events 45 | t.Run("Existing members see new members' join events", func(t *testing.T) { 46 | t.Parallel() 47 | // SyncJoinedTo already checks everything we need to know 48 | alice.MustSyncUntil(t, client.SyncReq{Since: incrementalSyncTokenBeforeBobJoinsRoom}, client.SyncJoinedTo(bob.UserID, roomID)) 49 | }) 50 | 51 | // sytest: Existing members see new members' presence 52 | // Split into initial and incremental sync cases in Complement. 53 | t.Run("Existing members see new members' presence (in initial sync)", func(t *testing.T) { 54 | runtime.SkipIf(t, runtime.Dendrite) // FIXME: https://github.com/matrix-org/matrix-spec/issues/1374 55 | t.Parallel() 56 | // First we sync to make sure bob to have joined the room... 57 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 58 | // ...and then we do another initial sync - this time waiting for bob's presence - to confirm we can get that. 59 | alice.MustSyncUntil(t, client.SyncReq{}, 60 | client.SyncJoinedTo(bob.UserID, roomID), 61 | client.SyncPresenceHas(bob.UserID, nil), 62 | ) 63 | }) 64 | 65 | // sytest: Existing members see new members' presence 66 | // Split into initial and incremental sync cases in Complement. 67 | t.Run("Existing members see new members' presence (in incremental sync)", func(t *testing.T) { 68 | t.Parallel() 69 | alice.MustSyncUntil(t, client.SyncReq{Since: incrementalSyncTokenBeforeBobJoinsRoom}, 70 | client.SyncJoinedTo(bob.UserID, roomID), 71 | client.SyncPresenceHas(bob.UserID, nil), 72 | ) 73 | }) 74 | }) 75 | 76 | } 77 | -------------------------------------------------------------------------------- /tests/csapi/sync_filter_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "io/ioutil" 5 | "testing" 6 | 7 | "github.com/tidwall/gjson" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | ) 15 | 16 | func TestSyncFilter(t *testing.T) { 17 | deployment := complement.Deploy(t, 1) 18 | defer deployment.Destroy(t) 19 | authedClient := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 20 | // sytest: Can create filter 21 | t.Run("Can create filter", func(t *testing.T) { 22 | createFilter(t, authedClient, map[string]interface{}{ 23 | "room": map[string]interface{}{ 24 | "timeline": map[string]int{ 25 | "limit": 10, 26 | }, 27 | }, 28 | }) 29 | }) 30 | // sytest: Can download filter 31 | t.Run("Can download filter", func(t *testing.T) { 32 | filterID := createFilter(t, authedClient, map[string]interface{}{ 33 | "room": map[string]interface{}{ 34 | "timeline": map[string]int{ 35 | "limit": 10, 36 | }, 37 | }, 38 | }) 39 | res := authedClient.MustDo(t, "GET", []string{"_matrix", "client", "v3", "user", authedClient.UserID, "filter", filterID}) 40 | must.MatchResponse(t, res, match.HTTPResponse{ 41 | JSON: []match.JSON{ 42 | match.JSONKeyPresent("room"), 43 | match.JSONKeyEqual("room.timeline.limit", float64(10)), 44 | }, 45 | }) 46 | 47 | }) 48 | } 49 | 50 | func createFilter(t *testing.T, c *client.CSAPI, filterContent map[string]interface{}) string { 51 | t.Helper() 52 | res := c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "user", c.UserID, "filter"}, client.WithJSONBody(t, filterContent)) 53 | if res.StatusCode != 200 { 54 | t.Fatalf("MatchResponse got status %d want 200", res.StatusCode) 55 | } 56 | body, err := ioutil.ReadAll(res.Body) 57 | if err != nil { 58 | t.Fatalf("unable to read response body: %v", err) 59 | } 60 | 61 | filterID := gjson.GetBytes(body, "filter_id").Str 62 | 63 | return filterID 64 | 65 | } 66 | -------------------------------------------------------------------------------- /tests/csapi/to_device_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/tidwall/gjson" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | ) 13 | 14 | // sytest: Can send a message directly to a device using PUT /sendToDevice 15 | // sytest: Can recv a device message using /sync 16 | // sytest: Can send a to-device message to two users which both receive it using /sync 17 | func TestToDeviceMessages(t *testing.T) { 18 | deployment := complement.Deploy(t, 1) 19 | defer deployment.Destroy(t) 20 | 21 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 22 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 23 | charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 24 | LocalpartSuffix: "charlie", 25 | Password: "charliepassword", 26 | }) 27 | 28 | _, bobSince := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 29 | _, charlieSince := charlie.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 30 | 31 | content := map[string]interface{}{ 32 | "my_key": "my_value", 33 | } 34 | 35 | alice.MustSendToDeviceMessages(t, "my.test.type", map[string]map[string]map[string]interface{}{ 36 | bob.UserID: { 37 | bob.DeviceID: content, 38 | }, 39 | charlie.UserID: { 40 | charlie.DeviceID: content, 41 | }, 42 | }) 43 | 44 | checkEvent := func(result gjson.Result) bool { 45 | if result.Get("type").Str != "my.test.type" { 46 | return false 47 | } 48 | 49 | evContentRes := result.Get("content") 50 | 51 | if !evContentRes.Exists() || !evContentRes.IsObject() { 52 | return false 53 | } 54 | 55 | evContent := evContentRes.Value() 56 | 57 | return reflect.DeepEqual(evContent, content) 58 | } 59 | 60 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSince}, client.SyncToDeviceHas(alice.UserID, checkEvent)) 61 | 62 | charlie.MustSyncUntil(t, client.SyncReq{Since: charlieSince}, client.SyncToDeviceHas(alice.UserID, checkEvent)) 63 | 64 | } 65 | -------------------------------------------------------------------------------- /tests/csapi/url_preview_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/tidwall/gjson" 12 | 13 | "github.com/matrix-org/complement" 14 | "github.com/matrix-org/complement/client" 15 | "github.com/matrix-org/complement/helpers" 16 | "github.com/matrix-org/complement/internal/data" 17 | "github.com/matrix-org/complement/internal/web" 18 | "github.com/matrix-org/complement/match" 19 | "github.com/matrix-org/complement/must" 20 | "github.com/matrix-org/complement/runtime" 21 | ) 22 | 23 | const oGraphTitle = "The Rock" 24 | const oGraphType = "video.movie" 25 | const oGraphUrl = "http://www.imdb.com/title/tt0117500/" 26 | const oGraphImage = "test.png" 27 | 28 | var oGraphHtml = fmt.Sprintf(` 29 | 30 | 31 | The Rock (1996) 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | `, oGraphTitle, oGraphType, oGraphUrl, oGraphImage) 40 | 41 | // sytest: Test URL preview 42 | func TestUrlPreview(t *testing.T) { 43 | runtime.SkipIf(t, runtime.Dendrite) // FIXME: https://github.com/matrix-org/dendrite/issues/621 44 | 45 | deployment := complement.Deploy(t, 1) 46 | defer deployment.Destroy(t) 47 | 48 | webServer := web.NewServer(t, deployment.GetConfig(), func(router *mux.Router) { 49 | router.HandleFunc("/test.png", func(w http.ResponseWriter, req *http.Request) { 50 | t.Log("/test.png fetched") 51 | 52 | w.Header().Set("Content-Type", "image/png") 53 | w.WriteHeader(200) 54 | w.Write(data.MatrixPng) 55 | }).Methods("GET") 56 | router.HandleFunc("/test.html", func(w http.ResponseWriter, req *http.Request) { 57 | t.Log("/test.html fetched") 58 | 59 | w.Header().Set("Content-Type", "text/html") 60 | w.WriteHeader(200) 61 | w.Write([]byte(oGraphHtml)) 62 | }).Methods("GET") 63 | }) 64 | defer webServer.Close() 65 | 66 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 67 | 68 | res := alice.MustDo(t, "GET", []string{"_matrix", "media", "v3", "preview_url"}, 69 | client.WithQueries(url.Values{ 70 | "url": []string{webServer.URL + "/test.html"}, 71 | }), 72 | ) 73 | 74 | var e = client.GjsonEscape 75 | 76 | must.MatchResponse(t, res, match.HTTPResponse{ 77 | JSON: []match.JSON{ 78 | match.JSONKeyEqual(e("og:title"), oGraphTitle), 79 | match.JSONKeyEqual(e("og:type"), oGraphType), 80 | match.JSONKeyEqual(e("og:url"), oGraphUrl), 81 | match.JSONKeyEqual(e("matrix:image:size"), 2239.0), 82 | match.JSONKeyEqual(e("og:image:height"), 129.0), 83 | match.JSONKeyEqual(e("og:image:width"), 279.0), 84 | func(body gjson.Result) error { 85 | res := body.Get(e("og:image")) 86 | if !res.Exists() { 87 | return fmt.Errorf("can not find key og:image") 88 | } 89 | if !strings.HasPrefix(res.Str, "mxc://") { 90 | return fmt.Errorf("image is not mxc") 91 | } 92 | 93 | return nil 94 | }, 95 | }, 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /tests/csapi/user_query_keys_test.go: -------------------------------------------------------------------------------- 1 | package csapi_tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/match" 10 | "github.com/matrix-org/complement/must" 11 | ) 12 | 13 | // Endpoint: https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-keys-query 14 | // This test asserts that the server is correctly rejecting input that does not match the request format given. 15 | // Specifically, it replaces [$device_id] with { $device_id: bool } which, if not type checked, will be processed 16 | // like an array in Python and hence go un-noticed. In Go however it will result in a 400. The correct behaviour is 17 | // to return a 400. Element iOS uses this erroneous format. 18 | func TestKeysQueryWithDeviceIDAsObjectFails(t *testing.T) { 19 | deployment := complement.Deploy(t, 1) 20 | defer deployment.Destroy(t) 21 | 22 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 23 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 24 | res := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "keys", "query"}, 25 | client.WithJSONBody(t, map[string]interface{}{ 26 | "device_keys": map[string]interface{}{ 27 | bob.UserID: map[string]bool{ 28 | "device_id1": true, 29 | "device_id2": true, 30 | }, 31 | }, 32 | }), 33 | ) 34 | must.MatchResponse(t, res, match.HTTPResponse{ 35 | StatusCode: 400, 36 | JSON: []match.JSON{ 37 | match.JSONKeyEqual("errcode", "M_BAD_JSON"), 38 | }, 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /tests/federation_media_content_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "bytes" 5 | "github.com/matrix-org/complement" 6 | "github.com/matrix-org/complement/helpers" 7 | "github.com/matrix-org/complement/internal/data" 8 | "testing" 9 | ) 10 | 11 | func TestContentMediaV1(t *testing.T) { 12 | deployment := complement.Deploy(t, 2) 13 | defer deployment.Destroy(t) 14 | 15 | hs1 := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 16 | hs2 := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 17 | 18 | wantContentType := "img/png" 19 | mxcUri := hs1.UploadContent(t, data.MatrixPng, "test.png", wantContentType) 20 | 21 | content, contentType := hs2.DownloadContentAuthenticated(t, mxcUri) 22 | if !bytes.Equal(data.MatrixPng, content) { 23 | t.Fatalf("uploaded and downloaded content doesn't match: want %v\ngot\n%v", data.MatrixPng, content) 24 | } 25 | if contentType != wantContentType { 26 | t.Fatalf("expected contentType to be \n %s, got \n %s", wantContentType, contentType) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/federation_presence_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tidwall/gjson" 7 | 8 | "github.com/matrix-org/complement" 9 | "github.com/matrix-org/complement/b" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/gomatrixserverlib/spec" 13 | ) 14 | 15 | func TestRemotePresence(t *testing.T) { 16 | deployment := complement.Deploy(t, 2) 17 | defer deployment.Destroy(t) 18 | 19 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) 20 | bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{LocalpartSuffix: "bob"}) 21 | 22 | // for presence to be sent over federation alice and bob must share a room 23 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 24 | bob.MustJoinRoom(t, roomID, []spec.ServerName{ 25 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 26 | }) 27 | 28 | // sytest: Presence changes are also reported to remote room members 29 | t.Run("Presence changes are also reported to remote room members", func(t *testing.T) { 30 | _, bobSinceToken := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 31 | 32 | statusMsg := "Update for room members" 33 | alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}, 34 | client.WithJSONBody(t, map[string]interface{}{ 35 | "status_msg": statusMsg, 36 | "presence": "online", 37 | }), 38 | ) 39 | 40 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSinceToken}, 41 | client.SyncPresenceHas(alice.UserID, b.Ptr("online"), func(ev gjson.Result) bool { 42 | return ev.Get("content.status_msg").Str == statusMsg 43 | }), 44 | ) 45 | }) 46 | // sytest: Presence changes to UNAVAILABLE are reported to remote room members 47 | t.Run("Presence changes to UNAVAILABLE are reported to remote room members", func(t *testing.T) { 48 | _, bobSinceToken := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 49 | 50 | alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "presence", alice.UserID, "status"}, 51 | client.WithJSONBody(t, map[string]interface{}{ 52 | "presence": "unavailable", 53 | }), 54 | ) 55 | 56 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSinceToken}, 57 | client.SyncPresenceHas(alice.UserID, b.Ptr("unavailable")), 58 | ) 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /tests/federation_query_profile_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/gomatrixserverlib/fclient" 11 | 12 | "github.com/matrix-org/complement/federation" 13 | "github.com/matrix-org/complement/helpers" 14 | "github.com/matrix-org/complement/match" 15 | "github.com/matrix-org/complement/must" 16 | ) 17 | 18 | // TODO: 19 | // Inbound federation can query profile data 20 | 21 | // Test that the server can make outbound federation profile requests 22 | // https://matrix.org/docs/spec/server_server/latest#get-matrix-federation-v1-query-profile 23 | func TestOutboundFederationProfile(t *testing.T) { 24 | deployment := complement.Deploy(t, 1) 25 | defer deployment.Destroy(t) 26 | 27 | srv := federation.NewServer(t, deployment, 28 | federation.HandleKeyRequests(), 29 | ) 30 | cancel := srv.Listen() 31 | defer cancel() 32 | 33 | // sytest: Outbound federation can query profile data 34 | t.Run("Outbound federation can query profile data", func(t *testing.T) { 35 | remoteUserID := srv.UserID("user") 36 | remoteDisplayName := "my remote display name" 37 | 38 | srv.Mux().Handle("/_matrix/federation/v1/query/profile", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 39 | userID := req.URL.Query().Get("user_id") 40 | if userID != remoteUserID { 41 | w.WriteHeader(500) 42 | t.Fatalf("GET /_matrix/federation/v1/query/profile with wrong user ID, got '%s' want '%s'", userID, remoteUserID) 43 | return 44 | } 45 | resBody, err := json.Marshal(struct { 46 | Displayname string `json:"displayname"` 47 | AvatarURL string `json:"avatar_url"` 48 | }{ 49 | remoteDisplayName, "", 50 | }) 51 | if err != nil { 52 | w.WriteHeader(500) 53 | t.Fatalf("GET /_matrix/federation/v1/query/profile failed to marshal response: %s", err) 54 | return 55 | } 56 | w.WriteHeader(200) 57 | w.Write(resBody) 58 | })).Methods("GET") 59 | 60 | // query the display name which should do an outbound federation hit 61 | unauthedClient := deployment.UnauthenticatedClient(t, "hs1") 62 | gotDisplayName := unauthedClient.MustGetDisplayName(t, remoteUserID) 63 | must.Equal(t, gotDisplayName, remoteDisplayName, "display name mismatch") 64 | }) 65 | } 66 | 67 | func TestInboundFederationProfile(t *testing.T) { 68 | deployment := complement.Deploy(t, 1) 69 | defer deployment.Destroy(t) 70 | 71 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 72 | 73 | srv := federation.NewServer(t, deployment, 74 | federation.HandleKeyRequests(), 75 | ) 76 | cancel := srv.Listen() 77 | defer cancel() 78 | origin := srv.ServerName() 79 | 80 | // sytest: Non-numeric ports in server names are rejected 81 | t.Run("Non-numeric ports in server names are rejected", func(t *testing.T) { 82 | fedReq := fclient.NewFederationRequest( 83 | "GET", 84 | origin, 85 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 86 | "/_matrix/federation/v1/query/profile"+ 87 | "?user_id=@user1:localhost:http"+ 88 | "&field=displayname", 89 | ) 90 | 91 | resp, err := srv.DoFederationRequest(context.Background(), t, deployment, fedReq) 92 | 93 | must.NotError(t, "failed to GET /profile", err) 94 | 95 | must.MatchResponse(t, resp, match.HTTPResponse{ 96 | StatusCode: 400, 97 | }) 98 | }) 99 | 100 | // sytest: Inbound federation can query profile data 101 | t.Run("Inbound federation can query profile data", func(t *testing.T) { 102 | const alicePublicName = "Alice Cooper" 103 | 104 | alice.MustSetDisplayName(t, alicePublicName) 105 | 106 | fedReq := fclient.NewFederationRequest( 107 | "GET", 108 | origin, 109 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 110 | "/_matrix/federation/v1/query/profile"+ 111 | "?user_id="+alice.UserID+ 112 | "&field=displayname", 113 | ) 114 | 115 | resp, err := srv.DoFederationRequest(context.Background(), t, deployment, fedReq) 116 | 117 | must.NotError(t, "failed to GET /profile", err) 118 | 119 | must.MatchResponse(t, resp, match.HTTPResponse{ 120 | StatusCode: 200, 121 | JSON: []match.JSON{ 122 | match.JSONKeyEqual("displayname", alicePublicName), 123 | }, 124 | }) 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /tests/federation_redaction_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/matrix-org/complement" 8 | "github.com/matrix-org/complement/federation" 9 | "github.com/matrix-org/complement/helpers" 10 | "github.com/matrix-org/complement/must" 11 | "github.com/matrix-org/complement/runtime" 12 | "github.com/matrix-org/gomatrixserverlib" 13 | "github.com/matrix-org/gomatrixserverlib/spec" 14 | ) 15 | 16 | // test that a redaction is sent out over federation even if we don't have the original event 17 | func TestFederationRedactSendsWithoutEvent(t *testing.T) { 18 | runtime.SkipIf(t, runtime.Dendrite) 19 | 20 | deployment := complement.Deploy(t, 1) 21 | defer deployment.Destroy(t) 22 | 23 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 24 | 25 | waiter := helpers.NewWaiter() 26 | wantEventType := "m.room.redaction" 27 | 28 | // create a remote homeserver 29 | srv := federation.NewServer(t, deployment, 30 | federation.HandleKeyRequests(), 31 | federation.HandleMakeSendJoinRequests(), 32 | federation.HandleTransactionRequests( 33 | // listen for PDU events in transactions 34 | func(ev gomatrixserverlib.PDU) { 35 | defer waiter.Finish() 36 | 37 | must.Equal(t, ev.Type(), wantEventType, "wrong event type") 38 | }, 39 | nil, 40 | ), 41 | ) 42 | cancel := srv.Listen() 43 | defer cancel() 44 | 45 | // create username 46 | charlie := srv.UserID("charlie") 47 | 48 | ver := alice.GetDefaultRoomVersion(t) 49 | 50 | // the remote homeserver creates a public room allowing anyone to redact 51 | initalEvents := federation.InitialRoomEvents(ver, charlie) 52 | plEvent := initalEvents[2] 53 | plEvent.Content["redact"] = 0 54 | serverRoom := srv.MustMakeRoom(t, ver, initalEvents) 55 | roomAlias := srv.MakeAliasMapping("flibble", serverRoom.RoomID) 56 | 57 | // the local homeserver joins the room 58 | alice.MustJoinRoom(t, roomAlias, []spec.ServerName{srv.ServerName()}) 59 | 60 | // inject event to redact in the room 61 | badEvent := srv.MustCreateEvent(t, serverRoom, federation.Event{ 62 | Type: "m.room.message", 63 | Sender: charlie, 64 | Content: map[string]interface{}{ 65 | "body": "666", 66 | }}) 67 | serverRoom.AddEvent(badEvent) 68 | 69 | eventID := badEvent.EventID() 70 | fullServerName := srv.ServerName() 71 | eventToRedact := eventID + ":" + string(fullServerName) 72 | 73 | // the client sends a request to the local homeserver to send the redaction 74 | redactionEventID := alice.MustSendRedaction(t, serverRoom.RoomID, map[string]interface{}{ 75 | "reason": "reasons...", 76 | }, eventToRedact) 77 | 78 | // wait for redaction to arrive at remote homeserver 79 | waiter.Wait(t, 1*time.Second) 80 | 81 | // Check that the last event in the room is now the redaction 82 | lastEvent := serverRoom.Timeline[len(serverRoom.Timeline)-1] 83 | lastEventType := lastEvent.Type() 84 | must.Equal(t, lastEventType, "m.room.redaction", "incorrect event type") 85 | 86 | // check that the event id of the redaction sent by alice is the same as the redaction event in the room 87 | must.Equal(t, lastEvent.EventID(), redactionEventID, "incorrect event id") 88 | } 89 | -------------------------------------------------------------------------------- /tests/federation_room_alias_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/complement/match" 10 | "github.com/matrix-org/complement/must" 11 | ) 12 | 13 | // sytest: Remote room alias queries can handle Unicode 14 | func TestRemoteAliasRequestsUnderstandUnicode(t *testing.T) { 15 | deployment := complement.Deploy(t, 2) 16 | defer deployment.Destroy(t) 17 | 18 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 19 | bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 20 | 21 | const unicodeAlias = "#老虎£я🤨👉ඞ:hs1" 22 | 23 | roomID := alice.MustCreateRoom(t, map[string]interface{}{}) 24 | 25 | alice.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "directory", "room", unicodeAlias}, client.WithJSONBody(t, map[string]interface{}{ 26 | "room_id": roomID, 27 | })) 28 | 29 | res := bob.Do(t, "GET", []string{"_matrix", "client", "v3", "directory", "room", unicodeAlias}) 30 | must.MatchResponse(t, res, match.HTTPResponse{ 31 | StatusCode: 200, 32 | JSON: []match.JSON{ 33 | match.JSONKeyEqual("room_id", roomID), 34 | }, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /tests/federation_room_ban_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/gomatrixserverlib/spec" 10 | ) 11 | 12 | // Regression test for https://github.com/matrix-org/synapse/issues/1563 13 | // Create a federation room. Bob bans Alice. Bob unbans Alice. Bob invites Alice (unbanning her). Ensure the invite is 14 | // received and can be accepted. 15 | func TestUnbanViaInvite(t *testing.T) { 16 | deployment := complement.Deploy(t, 2) 17 | defer deployment.Destroy(t) 18 | 19 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 20 | bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 21 | 22 | roomID := bob.MustCreateRoom(t, map[string]interface{}{ 23 | "preset": "public_chat", 24 | }) 25 | alice.MustJoinRoom(t, roomID, []spec.ServerName{ 26 | deployment.GetFullyQualifiedHomeserverName(t, "hs2"), 27 | }) 28 | 29 | // Ban Alice 30 | bob.MustDo(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "ban"}, client.WithJSONBody(t, map[string]interface{}{ 31 | "user_id": alice.UserID, 32 | })) 33 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncLeftFrom(alice.UserID, roomID)) 34 | 35 | // Unban Alice 36 | bob.MustDo(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "unban"}, client.WithJSONBody(t, map[string]interface{}{ 37 | "user_id": alice.UserID, 38 | })) 39 | bob.MustSyncUntil(t, client.SyncReq{}, client.SyncLeftFrom(alice.UserID, roomID)) 40 | 41 | // Re-invite Alice 42 | bob.MustInviteRoom(t, roomID, alice.UserID) 43 | bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(alice.UserID, roomID)) 44 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(alice.UserID, roomID)) 45 | 46 | // Alice accepts (this is what previously failed in the issue) 47 | alice.MustJoinRoom(t, roomID, []spec.ServerName{ 48 | deployment.GetFullyQualifiedHomeserverName(t, "hs2"), 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /tests/federation_room_invite_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/matrix-org/complement" 8 | "github.com/matrix-org/gomatrixserverlib" 9 | 10 | "github.com/matrix-org/complement/federation" 11 | "github.com/matrix-org/complement/helpers" 12 | ) 13 | 14 | // This test ensures that invite rejections are correctly sent out over federation. 15 | // 16 | // We start with two users in a room - alice@hs1, and 'delia' on the Complement test server. 17 | // alice sends an invite to charlie@hs2, which he rejects. 18 | // We check that delia sees the rejection. 19 | func TestFederationRejectInvite(t *testing.T) { 20 | deployment := complement.Deploy(t, 2) 21 | defer deployment.Destroy(t) 22 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 23 | charlie := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 24 | 25 | // we'll awaken this Waiter when we receive a membership event for Charlie 26 | var waiter *helpers.Waiter 27 | 28 | srv := federation.NewServer(t, deployment, 29 | federation.HandleKeyRequests(), 30 | federation.HandleTransactionRequests(func(ev gomatrixserverlib.PDU) { 31 | sk := "" 32 | if ev.StateKey() != nil { 33 | sk = *ev.StateKey() 34 | } 35 | t.Logf("Received PDU %s/%s", ev.Type(), sk) 36 | if waiter != nil && ev.Type() == "m.room.member" && ev.StateKeyEquals(charlie.UserID) { 37 | waiter.Finish() 38 | } 39 | }, nil), 40 | ) 41 | srv.UnexpectedRequestsAreErrors = false 42 | cancel := srv.Listen() 43 | defer cancel() 44 | delia := srv.UserID("delia") 45 | 46 | // Alice creates the room, and delia joins 47 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 48 | room := srv.MustJoinRoom(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), roomID, delia) 49 | 50 | // Alice invites Charlie; Delia should see the invite 51 | waiter = helpers.NewWaiter() 52 | alice.MustInviteRoom(t, roomID, charlie.UserID) 53 | waiter.Wait(t, 5*time.Second) 54 | room.MustHaveMembershipForUser(t, charlie.UserID, "invite") 55 | 56 | // Charlie rejects the invite; Delia should see the rejection. 57 | waiter = helpers.NewWaiter() 58 | charlie.MustLeaveRoom(t, roomID) 59 | waiter.Wait(t, 5*time.Second) 60 | room.MustHaveMembershipForUser(t, charlie.UserID, "leave") 61 | } 62 | -------------------------------------------------------------------------------- /tests/federation_room_typing_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | "github.com/matrix-org/complement/client" 8 | "github.com/matrix-org/complement/helpers" 9 | "github.com/matrix-org/gomatrixserverlib/spec" 10 | ) 11 | 12 | // sytest: Typing notifications also sent to remote room members 13 | func TestRemoteTyping(t *testing.T) { 14 | deployment := complement.Deploy(t, 2) 15 | defer deployment.Destroy(t) 16 | 17 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 18 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 19 | charlie := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 20 | 21 | roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) 22 | bob.MustJoinRoom(t, roomID, nil) 23 | charlie.MustJoinRoom(t, roomID, []spec.ServerName{ 24 | deployment.GetFullyQualifiedHomeserverName(t, "hs1"), 25 | }) 26 | 27 | bobToken := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) 28 | charlieToken := charlie.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(charlie.UserID, roomID)) 29 | 30 | alice.SendTyping(t, roomID, true, 10000) 31 | 32 | bob.MustSyncUntil(t, client.SyncReq{Since: bobToken}, client.SyncUsersTyping(roomID, []string{alice.UserID})) 33 | 34 | charlie.MustSyncUntil(t, client.SyncReq{Since: charlieToken}, client.SyncUsersTyping(roomID, []string{alice.UserID})) 35 | } 36 | -------------------------------------------------------------------------------- /tests/federation_sync_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/b" 11 | "github.com/matrix-org/complement/client" 12 | "github.com/matrix-org/complement/federation" 13 | "github.com/matrix-org/complement/helpers" 14 | "github.com/matrix-org/complement/match" 15 | "github.com/matrix-org/complement/must" 16 | "github.com/matrix-org/complement/runtime" 17 | "github.com/matrix-org/gomatrixserverlib/spec" 18 | "github.com/tidwall/gjson" 19 | ) 20 | 21 | // Tests https://github.com/element-hq/synapse/issues/16928 22 | // To set up: 23 | // - Alice and Bob join the same room, sends E1. 24 | // - Alice sends event E3. 25 | // - Bob forks the graph at E1 and sends S2. 26 | // - Alice sends event E4 on the same fork as E3. 27 | // - Alice sends event E5 merging the forks. 28 | // - Alice sync with timeline_limit=1 and a filter that skips E5 29 | func TestSyncOmitsStateChangeOnFilteredEvents(t *testing.T) { 30 | runtime.SkipIf(t, runtime.Dendrite) // S2 is put in the timeline, not state. 31 | deployment := complement.Deploy(t, 1) 32 | defer deployment.Destroy(t) 33 | srv := federation.NewServer(t, deployment, 34 | federation.HandleKeyRequests(), 35 | federation.HandleMakeSendJoinRequests(), 36 | federation.HandleTransactionRequests(nil, nil), 37 | ) 38 | srv.UnexpectedRequestsAreErrors = false 39 | cancel := srv.Listen() 40 | defer cancel() 41 | 42 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 43 | bob := srv.UserID("bob") 44 | ver := alice.GetDefaultRoomVersion(t) 45 | serverRoom := srv.MustMakeRoom(t, ver, federation.InitialRoomEvents(ver, bob)) 46 | 47 | // Join Alice to the new room on the federation server and send E1. 48 | alice.MustJoinRoom(t, serverRoom.RoomID, []spec.ServerName{srv.ServerName()}) 49 | e1 := alice.SendEventSynced(t, serverRoom.RoomID, b.Event{ 50 | Type: "m.room.message", 51 | Content: map[string]interface{}{ 52 | "msgtype": "m.text", 53 | "body": "E1", 54 | }, 55 | }) 56 | 57 | // wait until bob's server sees e1 58 | serverRoom.WaiterForEvent(e1).Waitf(t, 5*time.Second, "failed to see e1 (%s) on complement server", e1) 59 | 60 | // create S2 but don't send it yet, prev_events will be set to [e1] 61 | roomName := "I am the room name, S2" 62 | s2 := srv.MustCreateEvent(t, serverRoom, federation.Event{ 63 | Type: "m.room.name", 64 | StateKey: b.Ptr(""), 65 | Sender: bob, 66 | Content: map[string]interface{}{ 67 | "name": roomName, 68 | }, 69 | }) 70 | 71 | // Alice sends E3 & E4 72 | alice.SendEventSynced(t, serverRoom.RoomID, b.Event{ 73 | Type: "m.room.message", 74 | Content: map[string]interface{}{ 75 | "msgtype": "m.text", 76 | "body": "E3", 77 | }, 78 | }) 79 | alice.SendEventSynced(t, serverRoom.RoomID, b.Event{ 80 | Type: "m.room.message", 81 | Content: map[string]interface{}{ 82 | "msgtype": "m.text", 83 | "body": "E4", 84 | }, 85 | }) 86 | 87 | // fork the dag earlier at e1 and send s2 88 | srv.MustSendTransaction(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), []json.RawMessage{s2.JSON()}, nil) 89 | 90 | // wait until we see S2 to ensure the server has processed this. 91 | alice.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(serverRoom.RoomID, s2.EventID())) 92 | 93 | // now send E5, merging the forks 94 | alice.SendEventSynced(t, serverRoom.RoomID, b.Event{ 95 | Type: "please_filter_me", 96 | Content: map[string]interface{}{ 97 | "msgtype": "m.text", 98 | "body": "E5", 99 | }, 100 | }) 101 | 102 | // now do a sync request which filters events of type 'please_filter_me', and ensure we still see the 103 | // correct room name. Note we don't need to SyncUntil here as we have all the data in the right places 104 | // already. 105 | res, _ := alice.MustSync(t, client.SyncReq{ 106 | Filter: `{ 107 | "room": { 108 | "timeline": { 109 | "not_types": ["please_filter_me"], 110 | "limit": 1 111 | } 112 | } 113 | }`, 114 | }) 115 | must.MatchGJSON(t, res, match.JSONCheckOff( 116 | // look in this array 117 | fmt.Sprintf("rooms.join.%s.state.events", client.GjsonEscape(serverRoom.RoomID)), 118 | // for these items 119 | []interface{}{s2.EventID()}, 120 | // and map them first into this format 121 | match.CheckOffMapper(func(r gjson.Result) interface{} { 122 | return r.Get("event_id").Str 123 | }), match.CheckOffAllowUnwanted(), 124 | )) 125 | } 126 | -------------------------------------------------------------------------------- /tests/federation_to_device_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/matrix-org/complement" 9 | "github.com/matrix-org/complement/client" 10 | "github.com/matrix-org/complement/helpers" 11 | "github.com/tidwall/gjson" 12 | ) 13 | 14 | // Test that to-device messages can go from one homeserver to another. 15 | func TestToDeviceMessagesOverFederation(t *testing.T) { 16 | deployment := complement.Deploy(t, 2) 17 | defer deployment.Destroy(t) 18 | 19 | testCases := []struct { 20 | name string 21 | makeUnreachable func(t *testing.T) 22 | makeReachable func(t *testing.T) 23 | }{ 24 | { 25 | name: "good connectivity", 26 | makeUnreachable: func(t *testing.T) {}, 27 | makeReachable: func(t *testing.T) {}, 28 | }, 29 | { 30 | // cut networking but keep in-memory state 31 | name: "interrupted connectivity", 32 | makeUnreachable: func(t *testing.T) { 33 | deployment.StopServer(t, "hs2") 34 | }, 35 | makeReachable: func(t *testing.T) { 36 | deployment.StartServer(t, "hs2") 37 | }, 38 | }, 39 | { 40 | // interesting because this nukes memory 41 | name: "stopped server", 42 | makeUnreachable: func(t *testing.T) { 43 | deployment.StopServer(t, "hs2") 44 | }, 45 | makeReachable: func(t *testing.T) { 46 | // kick over the sending server first to see if the server 47 | // remembers to resend on startup 48 | deployment.StopServer(t, "hs1") 49 | deployment.StartServer(t, "hs1") 50 | // now make the receiving server reachable. 51 | deployment.StartServer(t, "hs2") 52 | }, 53 | }, 54 | } 55 | 56 | for _, tc := range testCases { 57 | tc := tc 58 | t.Run(tc.name, func(t *testing.T) { 59 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 60 | LocalpartSuffix: "alice", 61 | }) 62 | bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{ 63 | LocalpartSuffix: "bob", 64 | }) 65 | // it might take a while for retries, so keep on syncing! 66 | bob.SyncUntilTimeout = 30 * time.Second 67 | 68 | _, bobSince := bob.MustSync(t, client.SyncReq{TimeoutMillis: "0"}) 69 | 70 | content := map[string]interface{}{ 71 | "my_key": "my_value", 72 | } 73 | 74 | tc.makeUnreachable(t) 75 | 76 | alice.MustSendToDeviceMessages(t, "my.test.type", map[string]map[string]map[string]interface{}{ 77 | bob.UserID: { 78 | bob.DeviceID: content, 79 | }, 80 | }) 81 | 82 | checkEvent := func(result gjson.Result) bool { 83 | if result.Get("type").Str != "my.test.type" { 84 | return false 85 | } 86 | 87 | evContentRes := result.Get("content") 88 | 89 | if !evContentRes.Exists() || !evContentRes.IsObject() { 90 | return false 91 | } 92 | 93 | evContent := evContentRes.Value() 94 | 95 | return reflect.DeepEqual(evContent, content) 96 | } 97 | // just in case the server returns 200 OK before flushing to disk, give it a grace period. 98 | // This is too nice of us given in the real world no grace is provided.. 99 | time.Sleep(time.Second) 100 | 101 | tc.makeReachable(t) 102 | 103 | bob.MustSyncUntil(t, client.SyncReq{Since: bobSince}, func(clientUserID string, topLevelSyncJSON gjson.Result) error { 104 | t.Logf("%s", topLevelSyncJSON.Raw) 105 | return client.SyncToDeviceHas(alice.UserID, checkEvent)(clientUserID, topLevelSyncJSON) 106 | }) 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/federation_unreject_rejected_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/matrix-org/complement" 8 | "github.com/matrix-org/complement/client" 9 | "github.com/matrix-org/complement/federation" 10 | "github.com/matrix-org/complement/helpers" 11 | "github.com/matrix-org/gomatrixserverlib/spec" 12 | ) 13 | 14 | // TestUnrejectRejectedEvents creates two events: A and B. 15 | // To start with, we're going to withhold A from the homeserver 16 | // and send event B. Event B should get rejected because event A 17 | // is referred to as a prev event but is missing. Then we'll 18 | // send event B again after sending event A. That should mean that 19 | // event B is unrejected on the second pass and will appear in 20 | // the /sync response AFTER event A. 21 | func TestUnrejectRejectedEvents(t *testing.T) { 22 | deployment := complement.Deploy(t, 1) 23 | defer deployment.Destroy(t) 24 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 25 | 26 | srv := federation.NewServer(t, deployment, 27 | federation.HandleKeyRequests(), 28 | federation.HandleMakeSendJoinRequests(), 29 | ) 30 | srv.UnexpectedRequestsAreErrors = false 31 | cancel := srv.Listen() 32 | defer cancel() 33 | bob := srv.UserID("bob") 34 | 35 | // Create a new room on the federation server. 36 | ver := alice.GetDefaultRoomVersion(t) 37 | serverRoom := srv.MustMakeRoom(t, ver, federation.InitialRoomEvents(ver, bob)) 38 | 39 | // Join Alice to the new room on the federation server. 40 | alice.MustJoinRoom(t, serverRoom.RoomID, []spec.ServerName{srv.ServerName()}) 41 | alice.MustSyncUntil( 42 | t, client.SyncReq{}, 43 | client.SyncJoinedTo(alice.UserID, serverRoom.RoomID), 44 | ) 45 | 46 | // Create the events. Event A will have whatever the current forward 47 | // extremities are as prev events. Event B will refer to event A only 48 | // to guarantee the test will work. 49 | eventA := srv.MustCreateEvent(t, serverRoom, federation.Event{ 50 | Type: "m.event.a", 51 | Sender: bob, 52 | Content: map[string]interface{}{ 53 | "event": "A", 54 | }, 55 | }) 56 | eventB := srv.MustCreateEvent(t, serverRoom, federation.Event{ 57 | Type: "m.event.b", 58 | Sender: bob, 59 | Content: map[string]interface{}{ 60 | "event": "B", 61 | }, 62 | PrevEvents: []string{eventA.EventID()}, 63 | }) 64 | 65 | // Send event B into the room. Event A at this point is unknown 66 | // to the homeserver and we're not going to respond to the events 67 | // request for it, so it should get rejected. 68 | srv.MustSendTransaction(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), []json.RawMessage{eventB.JSON()}, nil) 69 | 70 | // Now we're going to send Event A into the room, which should give 71 | // the server the prerequisite event to pass Event B later. This one 72 | // should appear in /sync. 73 | srv.MustSendTransaction(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), []json.RawMessage{eventA.JSON()}, nil) 74 | 75 | // Wait for event A to appear in the room. We're going to store the 76 | // sync token here because we want to assert on the next sync that 77 | // we're only getting new events since this one (i.e. events after A). 78 | since := alice.MustSyncUntil( 79 | t, client.SyncReq{}, 80 | client.SyncTimelineHasEventID(serverRoom.RoomID, eventA.EventID()), 81 | ) 82 | 83 | // Finally, send Event B again. This time it should be unrejected and 84 | // should be sent as a new event down /sync for the first time. 85 | srv.MustSendTransaction(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), []json.RawMessage{eventB.JSON()}, nil) 86 | 87 | // Now see if event B appears in the room. Use the since token from the 88 | // last sync to ensure we're only waiting for new events since event A. 89 | alice.MustSyncUntil( 90 | t, client.SyncReq{Since: since}, 91 | client.SyncTimelineHasEventID(serverRoom.RoomID, eventB.EventID()), 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /tests/knock_restricted_test.go: -------------------------------------------------------------------------------- 1 | //go:build !dendrite_blacklist 2 | // +build !dendrite_blacklist 3 | 4 | // This file contains tests for a join rule which mixes concepts of restricted joins 5 | // and knocking. This is implemented in room version 10. 6 | // 7 | // Generally, this is a combination of knocking_test and restricted_rooms_test. 8 | 9 | package tests 10 | 11 | import ( 12 | "testing" 13 | 14 | "github.com/matrix-org/complement" 15 | "github.com/matrix-org/complement/helpers" 16 | ) 17 | 18 | var ( 19 | roomVersion = "10" 20 | joinRule = "knock_restricted" 21 | ) 22 | 23 | // See TestKnocking 24 | func TestKnockingInMSC3787Room(t *testing.T) { 25 | doTestKnocking(t, roomVersion, joinRule) 26 | } 27 | 28 | // See TestKnockRoomsInPublicRoomsDirectory 29 | func TestKnockRoomsInPublicRoomsDirectoryInMSC3787Room(t *testing.T) { 30 | doTestKnockRoomsInPublicRoomsDirectory(t, roomVersion, joinRule) 31 | } 32 | 33 | // See TestCannotSendKnockViaSendKnock 34 | func TestCannotSendKnockViaSendKnockInMSC3787Room(t *testing.T) { 35 | testValidationForSendMembershipEndpoint(t, "/_matrix/federation/v1/send_knock", "knock", 36 | map[string]interface{}{ 37 | "preset": "public_chat", 38 | "room_version": roomVersion, 39 | }, 40 | ) 41 | } 42 | 43 | // See TestRestrictedRoomsLocalJoin 44 | func TestRestrictedRoomsLocalJoinInMSC3787Room(t *testing.T) { 45 | deployment := complement.Deploy(t, 1) 46 | defer deployment.Destroy(t) 47 | 48 | // Setup the user, allowed room, and restricted room. 49 | alice, allowed_room, room := setupRestrictedRoom(t, deployment, roomVersion, joinRule) 50 | 51 | // Create a second user on the same homeserver. 52 | bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 53 | 54 | // Execute the checks. 55 | checkRestrictedRoom(t, deployment, alice, bob, allowed_room, room, joinRule) 56 | } 57 | 58 | // See TestRestrictedRoomsRemoteJoin 59 | func TestRestrictedRoomsRemoteJoinInMSC3787Room(t *testing.T) { 60 | deployment := complement.Deploy(t, 2) 61 | defer deployment.Destroy(t) 62 | 63 | // Setup the user, allowed room, and restricted room. 64 | alice, allowed_room, room := setupRestrictedRoom(t, deployment, roomVersion, joinRule) 65 | 66 | // Create a second user on a different homeserver. 67 | bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{}) 68 | 69 | // Execute the checks. 70 | checkRestrictedRoom(t, deployment, alice, bob, allowed_room, room, joinRule) 71 | } 72 | 73 | // See TestRestrictedRoomsRemoteJoinLocalUser 74 | func TestRestrictedRoomsRemoteJoinLocalUserInMSC3787Room(t *testing.T) { 75 | doTestRestrictedRoomsRemoteJoinLocalUser(t, roomVersion, joinRule) 76 | } 77 | 78 | // See TestRestrictedRoomsRemoteJoinFailOver 79 | func TestRestrictedRoomsRemoteJoinFailOverInMSC3787Room(t *testing.T) { 80 | doTestRestrictedRoomsRemoteJoinFailOver(t, roomVersion, joinRule) 81 | } 82 | -------------------------------------------------------------------------------- /tests/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "fed") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc2836/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc2836") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3391/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3391") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3757/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3757") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3874/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3874") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3890/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3890") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3890/msc3890_test.go: -------------------------------------------------------------------------------- 1 | // This file contains tests for local notification settings as 2 | // defined by MSC3890, which you can read here: 3 | // https://github.com/matrix-org/matrix-doc/pull/3890 4 | 5 | package tests 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/matrix-org/complement" 11 | "github.com/matrix-org/complement/client" 12 | "github.com/matrix-org/complement/helpers" 13 | "github.com/matrix-org/complement/match" 14 | "github.com/matrix-org/complement/must" 15 | "github.com/tidwall/gjson" 16 | ) 17 | 18 | func TestDeletingDeviceRemovesDeviceLocalNotificationSettings(t *testing.T) { 19 | // Create a deployment with a single user 20 | deployment := complement.Deploy(t, 1) 21 | defer deployment.Destroy(t) 22 | 23 | t.Log("Alice registers on device 1 and logs in to device 2.") 24 | aliceLocalpart := "alice" 25 | alicePassword := "hunter2" 26 | aliceDeviceOne := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 27 | LocalpartSuffix: aliceLocalpart, 28 | Password: alicePassword, 29 | }) 30 | aliceDeviceTwo := deployment.Login(t, "hs1", aliceDeviceOne, helpers.LoginOpts{ 31 | Password: alicePassword, 32 | }) 33 | 34 | accountDataType := "org.matrix.msc3890.local_notification_settings." + aliceDeviceTwo.DeviceID 35 | accountDataContent := map[string]interface{}{"is_silenced": true} 36 | 37 | // Test deleting global account data. 38 | t.Run("Deleting a user's device should delete any local notification settings entries from their account data", func(t *testing.T) { 39 | // Retrieve a sync token for this user 40 | _, nextBatchToken := aliceDeviceOne.MustSync( 41 | t, 42 | client.SyncReq{}, 43 | ) 44 | 45 | t.Log("Using her first device, Alice creates some local notification settings in her account data for the second device.") 46 | aliceDeviceOne.MustSetGlobalAccountData( 47 | t, accountDataType, accountDataContent, 48 | ) 49 | 50 | checkAccountDataContent := func(r gjson.Result) bool { 51 | // Only listen for our test type 52 | if r.Get("type").Str != accountDataType { 53 | return false 54 | } 55 | return match.JSONKeyEqual("content", accountDataContent)(r) == nil 56 | } 57 | 58 | t.Log("Alice syncs on device 1 until she sees the account data she just wrote.") 59 | aliceDeviceOne.MustSyncUntil( 60 | t, 61 | client.SyncReq{ 62 | Since: nextBatchToken, 63 | }, 64 | client.SyncGlobalAccountDataHas(checkAccountDataContent), 65 | ) 66 | 67 | t.Log("Alice also checks for the account data she wrote on the dedicated account data endpoint.") 68 | res := aliceDeviceOne.MustGetGlobalAccountData(t, accountDataType) 69 | must.MatchResponse(t, res, match.HTTPResponse{ 70 | JSON: []match.JSON{ 71 | match.JSONKeyEqual("is_silenced", true), 72 | }, 73 | }) 74 | 75 | t.Log("Alice logs out her second device.") 76 | aliceDeviceTwo.MustDo(t, "POST", []string{"_matrix", "client", "v3", "logout"}) 77 | 78 | t.Log("Alice re-fetches the global account data. The response should now have status 404.") 79 | res = aliceDeviceOne.GetGlobalAccountData(t, accountDataType) 80 | must.MatchResponse(t, res, match.HTTPResponse{ 81 | StatusCode: 404, 82 | JSON: []match.JSON{ 83 | // A 404 can be generated for missing endpoints as well (which would have an errcode of `M_UNRECOGNIZED`). 84 | // Ensure we're getting the error we expect. 85 | match.JSONKeyEqual("errcode", "M_NOT_FOUND"), 86 | }, 87 | }) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /tests/msc3902/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3902") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3930/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3930") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3967/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc3967") 11 | } 12 | -------------------------------------------------------------------------------- /tests/msc3967/msc3967_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "encoding/base64" 6 | "encoding/json" 7 | "testing" 8 | 9 | "github.com/matrix-org/complement" 10 | "github.com/matrix-org/complement/client" 11 | "github.com/matrix-org/complement/helpers" 12 | "github.com/matrix-org/complement/match" 13 | "github.com/matrix-org/complement/must" 14 | "github.com/matrix-org/gomatrixserverlib" 15 | "github.com/tidwall/gjson" 16 | ) 17 | 18 | // MSC3967: Do not require UIA when first uploading cross signing keys 19 | // Tests that: 20 | // - No UIA required on POST /_matrix/client/v3/keys/device_signing/upload 21 | // - If x-signing keys exist, does require UIA. 22 | // - If reupload exactly the same x-signing keys, does not require UIA. 23 | func TestMSC3967(t *testing.T) { 24 | deployment := complement.Deploy(t, 1) 25 | defer deployment.Destroy(t) 26 | 27 | privKey := ed25519.NewKeyFromSeed([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32}) 28 | pubKey := base64.RawStdEncoding.EncodeToString(privKey.Public().(ed25519.PublicKey)) 29 | t.Logf("pub key => %s", pubKey) 30 | password := "helloworld" 31 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ 32 | Password: password, 33 | }) 34 | 35 | // uploading x-signing master key does not require UIA (so no 401) 36 | uploadBody := map[string]any{ 37 | "master_key": map[string]any{ 38 | "user_id": alice.UserID, 39 | "usage": []string{"master"}, 40 | "keys": map[string]string{ 41 | "ed25519:" + pubKey: pubKey, 42 | }, 43 | }, 44 | } 45 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, uploadBody)) 46 | 47 | // reuploading the exact same request does not require UIA (idempotent) 48 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, uploadBody)) 49 | 50 | // trying to replace the master key requires UIA 51 | pubKey2, _, err := ed25519.GenerateKey(nil) 52 | must.NotError(t, "failed to generate 2nd key", err) 53 | pubKey2Base64 := base64.RawStdEncoding.EncodeToString(pubKey2) 54 | t.Logf("pub key 2 => %s", pubKey2Base64) 55 | res := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, map[string]any{ 56 | "master_key": map[string]any{ 57 | "user_id": alice.UserID, 58 | "usage": []string{"master"}, 59 | "keys": map[string]string{ 60 | "ed25519:" + pubKey2Base64: pubKey2Base64, 61 | }, 62 | }, 63 | })) 64 | must.MatchResponse(t, res, match.HTTPResponse{ 65 | StatusCode: 401, 66 | JSON: []match.JSON{ 67 | match.JSONKeyPresent("flows"), 68 | }, 69 | }) 70 | 71 | // adding a key does require UIA 72 | ssKey, _, err := ed25519.GenerateKey(nil) 73 | must.NotError(t, "failed to generate self-signing key", err) 74 | ssKey64 := base64.RawStdEncoding.EncodeToString(ssKey) 75 | t.Logf("self-signing key => %s", ssKey64) 76 | // replace the body so no master_key in this request, because if we include it 77 | // Synapse will 500 as I guess you can't replace it?! 78 | uploadBody = map[string]any{ 79 | "self_signing_key": map[string]any{ 80 | "user_id": alice.UserID, 81 | "usage": []string{"self_signing"}, 82 | "keys": map[string]string{ 83 | "ed25519:" + ssKey64: ssKey64, 84 | }, 85 | }, 86 | } 87 | toSign, err := json.Marshal(uploadBody["self_signing_key"]) 88 | must.NotError(t, "failed to marshal req body", err) 89 | signedBody, err := gomatrixserverlib.SignJSON(alice.UserID, gomatrixserverlib.KeyID("ed25519:"+pubKey), privKey, toSign) 90 | must.NotError(t, "failed to sign json", err) 91 | uploadBody["self_signing_key"] = json.RawMessage(signedBody) 92 | res = alice.Do(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, uploadBody)) 93 | body := must.MatchResponse(t, res, match.HTTPResponse{ 94 | StatusCode: 401, 95 | JSON: []match.JSON{ 96 | match.JSONKeyPresent("flows"), 97 | }, 98 | }) 99 | 100 | // but if we UIA then it should work. 101 | uploadBody["auth"] = map[string]any{ 102 | "type": "m.login.password", 103 | "identifier": map[string]any{ 104 | "type": "m.id.user", 105 | "user": alice.UserID, 106 | }, 107 | "password": password, 108 | "session": gjson.ParseBytes(body).Get("session").Str, 109 | } 110 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, uploadBody)) 111 | 112 | // ensure the endpoint remains idempotent with the auth dict 113 | alice.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "device_signing", "upload"}, client.WithJSONBody(t, uploadBody)) 114 | } 115 | -------------------------------------------------------------------------------- /tests/msc4140/main_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/matrix-org/complement" 7 | ) 8 | 9 | func TestMain(m *testing.M) { 10 | complement.TestMain(m, "msc4140") 11 | } 12 | -------------------------------------------------------------------------------- /tests/unknown_endpoints_test.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/matrix-org/complement" 8 | "github.com/matrix-org/complement/client" 9 | "github.com/matrix-org/complement/helpers" 10 | "github.com/matrix-org/complement/match" 11 | "github.com/matrix-org/complement/must" 12 | ) 13 | 14 | func queryUnknownEndpoint(t *testing.T, user *client.CSAPI, paths []string) { 15 | t.Helper() 16 | 17 | res := user.Do(t, "GET", paths) 18 | must.MatchResponse(t, res, match.HTTPResponse{ 19 | StatusCode: http.StatusNotFound, 20 | JSON: []match.JSON{ 21 | match.JSONKeyEqual("errcode", "M_UNRECOGNIZED"), 22 | }, 23 | }) 24 | } 25 | 26 | func queryUnknownMethod(t *testing.T, user *client.CSAPI, method string, paths []string) { 27 | t.Helper() 28 | 29 | res := user.Do(t, method, paths) 30 | must.MatchResponse(t, res, match.HTTPResponse{ 31 | StatusCode: http.StatusMethodNotAllowed, 32 | JSON: []match.JSON{ 33 | match.JSONKeyEqual("errcode", "M_UNRECOGNIZED"), 34 | }, 35 | }) 36 | } 37 | 38 | // Homeservers should return a 404 for unknown endpoints and 405 for incorrect 39 | // methods to known endpoints. 40 | func TestUnknownEndpoints(t *testing.T) { 41 | deployment := complement.Deploy(t, 1) 42 | defer deployment.Destroy(t) 43 | 44 | alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) 45 | 46 | // A completely unknown prefix to the matrix project. 47 | t.Run("Unknown prefix", func(t *testing.T) { 48 | queryUnknownEndpoint(t, alice, []string{"_matrix", "unknown"}) 49 | }) 50 | 51 | // Unknown client-server endpoints. 52 | t.Run("Client-server endpoints", func(t *testing.T) { 53 | queryUnknownEndpoint(t, alice, []string{"_matrix", "client", "unknown"}) 54 | // v1 should exist, but not v1/unknown. 55 | queryUnknownEndpoint(t, alice, []string{"_matrix", "client", "v1", "unknown"}) 56 | queryUnknownEndpoint(t, alice, []string{"_matrix", "client", "v3", "room", "unknown"}) 57 | 58 | queryUnknownMethod(t, alice, "PUT", []string{"_matrix", "client", "v3", "login"}) 59 | }) 60 | 61 | // Unknown server-server endpoints. 62 | t.Run("Server-server endpoints", func(t *testing.T) { 63 | queryUnknownEndpoint(t, alice, []string{"_matrix", "federation", "unknown"}) 64 | // v1 should exist, but not v1/unknown. 65 | queryUnknownEndpoint(t, alice, []string{"_matrix", "federation", "v1", "unknown"}) 66 | 67 | queryUnknownMethod(t, alice, "PUT", []string{"_matrix", "federation", "v1", "version"}) 68 | }) 69 | 70 | // Unknown key endpoints (part of the Server-server API under a different prefix). 71 | t.Run("Key endpoints", func(t *testing.T) { 72 | queryUnknownEndpoint(t, alice, []string{"_matrix", "key", "unknown"}) 73 | // v3 should exist, but not v3/unknown. 74 | queryUnknownEndpoint(t, alice, []string{"_matrix", "key", "v2", "unknown"}) 75 | 76 | queryUnknownMethod(t, alice, "PUT", []string{"_matrix", "key", "v2", "query"}) 77 | }) 78 | 79 | // Unknown media endpoints. 80 | t.Run("Media endpoints", func(t *testing.T) { 81 | queryUnknownEndpoint(t, alice, []string{"_matrix", "media", "unknown"}) 82 | // v3 should exist, but not v3/unknown. 83 | queryUnknownEndpoint(t, alice, []string{"_matrix", "media", "v3", "unknown"}) 84 | 85 | queryUnknownMethod(t, alice, "PATCH", []string{"_matrix", "media", "v3", "upload"}) 86 | }) 87 | } 88 | --------------------------------------------------------------------------------