Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,14 @@ jobs:
strategy:
matrix:
python-version: ['3.10', '3.13']
test-dir: ['tests', 'matrix_is_tester']

steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
- run: uv run trial tests
- run: uv run trial ${{ matrix.test-dir }}

# a job which runs once all the other jobs are complete, thus allowing PRs to
# be merged.
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
--mount=type=bind,source=integration-tests/pyproject.toml,target=integration-tests/pyproject.toml \
uv sync --locked --no-install-project --no-dev --extra sentry --extra prometheus

# Step 2: Copy source and install the project
Expand Down
1 change: 1 addition & 0 deletions changelog.d/622.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Absorb matrix-is-tester as a workspace member and modernise for Python 3.12+.
20 changes: 20 additions & 0 deletions integration-tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
matrix-is-tester
================

matrix-is-tester is an integration testing system for Matrix Identity servers, similar
to sytest (although using python rather than perl).

To launch the IS server, it attempts to import
`matrix_is_test.launcher.MatrixIsTestLauncher`. This must be provided separately
for the specific ID server implementation to be tested. See sydent's implementation
for an example of how this works.

Sydent supplies its matrix-is-tester launcher in the project directory, so to launch
matrix_is_tester on sydent you would run:

```
PYTHONPATH="/path/to/sydent" trial matrix_is_tester
```

...which puts the launcher on the PYTHONPATH and invokes trial on matrix_is_tester (which
is assumed to already be on sys.path).
156 changes: 156 additions & 0 deletions integration-tests/matrix_is_tester/base_api_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python

# -*- coding: utf-8 -*-

# Copyright 2019 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import logging

from matrix_is_tester.is_api import IsApi
from matrix_is_tester.launch_is import get_or_launch_is
from matrix_is_tester.mailsink import get_shared_mailsink

logger = logging.getLogger(__name__)


class BaseApiTest(object):
"""
Not a test case itself, but can be subclassed to test APIs common
between versions.
"""

def setUp(self):
self.baseUrl = get_or_launch_is()

self.mailSink = get_shared_mailsink()

self.api = IsApi(self.baseUrl, self.API_VERSION, self.mailSink)

def test_ping(self):
body = self.api.ping()
self.assertEqual(body, {})

def test_request_email_code(self):
body = self.api.request_email_code("fakeemail1@nowhere.test", "sekrit", 1)
logger.info("Got response %r" % (body,))
self.assertIn("sid", body)
self.mailSink.get_mail()

def test_reject_invalid_email(self):
body = self.api.request_email_code(
"fakeemail1@nowhere.test@elsewhere.test", "sekrit", 1
)
self.assertEqual(body["errcode"], "M_INVALID_EMAIL")

def test_submit_email_code(self):
self.api.request_and_submit_email_code("fakeemail2@nowhere.test")

def test_submit_email_code_get(self):
req_response = self.api.request_email_code(
"steve@nowhere.test", "verysekrit", 1
)
sid = req_response["sid"]

token = self.api.get_token_from_mail()

body = self.api.submit_email_token_via_get(sid, "verysekrit", token)
self.assertEqual(body, b"matrix_is_tester:email_submit_get_response\n")

body = self.api.get_validated_threepid(sid, "verysekrit")

self.assertEqual(body["medium"], "email")
self.assertEqual(body["address"], "steve@nowhere.test")

def test_unverified_bind(self):
req_code_body = self.api.request_email_code(
"fakeemail5@nowhere.test", "sekrit", 1
)
# get the mail so we don't leave it in the queue
self.mailSink.get_mail()
body = self.api.bind_email(
req_code_body["sid"], "sekrit", "@commonapitests:127.0.0.1:4490"
)
self.assertEqual(body["errcode"], "M_SESSION_NOT_VALIDATED")

def test_get_validated_threepid(self):
params = self.api.request_and_submit_email_code("fakeemail4@nowhere.test")

body = self.api.get_validated_threepid(params["sid"], params["client_secret"])

self.assertEqual(body["medium"], "email")
self.assertEqual(body["address"], "fakeemail4@nowhere.test")

def test_get_validated_threepid_not_validated(self):
req_code_body = self.api.request_email_code(
"fakeemail5@nowhere.test", "sekrit", 1
)
# get the mail, otherwise the next test will get it
# instead of the one it was expecting
self.mailSink.get_mail()

get_val_body = self.api.get_validated_threepid(req_code_body["sid"], "sekrit")
self.assertEqual(get_val_body["errcode"], "M_SESSION_NOT_VALIDATED")

def test_store_invite(self):
body = self.api.store_invite(
{
"medium": "email",
"address": "ian@fake.test",
"room_id": "$aroom:fake.test",
"sender": "@commonapitests:127.0.0.1:4490",
"room_alias": "#alias:fake.test",
"room_avatar_url": "mxc://fake.test/roomavatar",
"room_name": "my excellent room",
"sender_display_name": "Ian Sender",
"sender_avatar_url": "mxc://fake.test/iansavatar",
}
)
self.assertGreater(len(body["token"]), 0)
# must be redacted
self.assertNotEqual(body["display_name"], "ian@fake.test")
self.assertGreater(len(body["public_keys"]), 0)

for k in body["public_keys"]:
is_valid_body = self.api.pubkey_is_valid(
k["key_validity_url"], k["public_key"]
)
self.assertTrue(is_valid_body["valid"])

mail = self.mailSink.get_mail()
logger.info("Got email (invite): %r" % (mail,))
mail_object = json.loads(mail["data"])
self.assertEqual(mail_object["token"], body["token"])
self.assertEqual(mail_object["room_alias"], "#alias:fake.test")
self.assertEqual(mail_object["room_avatar_url"], "mxc://fake.test/roomavatar")
self.assertEqual(mail_object["room_name"], "my excellent room")
self.assertEqual(mail_object["sender_display_name"], "Ian Sender")
self.assertEqual(mail_object["sender_avatar_url"], "mxc://fake.test/iansavatar")

def test_store_invite_bound_threepid(self):
params = self.api.request_and_submit_email_code("already_here@fake.test")
self.api.bind_email(
params["sid"], params["client_secret"], "@some_mxid:fake.test"
)

body = self.api.store_invite(
{
"medium": "email",
"address": "already_here@fake.test",
"room_id": "$aroom:fake.test",
"sender": "@commonapitests:127.0.0.1:4490",
}
)
self.assertEqual(body["errcode"], "M_THREEPID_IN_USE")
45 changes: 45 additions & 0 deletions integration-tests/matrix_is_tester/fakehs.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDP2WZtaCEt5WKM
NdQ/38/mb0Se58SuR6dyxkElC8aezrLpGPRBIjzyg7WKRiuu4mIvzEv5oqr/GEQO
8zsNKsrtyLUuayT5eJPGVEsCDwi+1GguuxjG75a/zQBMgBkRAD4bij/EBAPsPJBs
iENjphMYOylU1lmwvx2mN+ehZOeCP9sGRmUYgZRi5XYl5TtCXwpO4bi8iDzGw7a2
yhLqHXAWugYKHLnDauq4nPbBIdplUV6UqZ3i4KKPjta/EFIReURh3ZqBdLm94uDV
7wZM0WyCbccW0f0d8INjoLJFD7PfQJrjtvPtnrh+9tRL+FyPHAaSZHSYU+Kvm5CW
wYZXMrrnAgMBAAECggEAak0305DEF2MP6cHGEfz3qVUS9Wp37uJ6w3qd6sKBDMuO
OSUoFv/Zx/aQrG2C/eiOav/Dg6MsbVcNx8+iTfOq4b4a2+i0elquyWpnCmCCCoc7
2VqbK3Nx2BqSoo2JRGapXRBx2GBtWS8IdlmijZ5seaIYW2ldacX09gP1lVe0B6qQ
1rA4Hvl3847cY+Aodu1fL/qPvXYvhrxzEsLVzKCqEJpQWlPQlq8d3RrPa4JLT4P7
IJ9pKodcfc1GMdVI6PeBeAESSmwUBjlD0AmrR9BY9mai0gXs8d2I+ZAmVHqg2A4H
Q3r6gGOI3abpA4WQst1FdnBFniMnC22G54YaacfZkQKBgQDui6P69SlpN0Jm6lKF
eUuFSuYG0FczSDEO+hu4kCqkUc4QWfufBcGN0t9oOgZJeTgpQrN7AVljKPukcOcY
1/9td2y10KQki+oHi5dFqKiHTcqR29WM2Xz0fTw2gVYkkS1DFdu2RBJGBR33esnX
ew+CN9amwliVj2XURduAEexJ3QKBgQDfDsRVxFHGxdnWX0R3j4EuPbncPDhFI0HC
H6aidIoQDkVrl/4xd4EIHg48vbBTo4+mDD2Urtcw8Pi/Sd9kWFBRVhzsHk5Ls/x4
AcIW74/UhOmmDJ6gBRH5AnXGp6ga7pUgtaiKww7nkWNf5E1UHhHcY08Jf5QH3OMQ
EBe7Ja8FkwKBgQDlo4QeqThOU6YW0OjUGSp8jNfYI2Rut8aSdm+NQyvpt965mwZB
1ha4YxIykflPbeSEw/NoLKpSbTei3BV8syLvzJHYjZwWmqKW1OixZGWoq1ihBZIU
36IM8yquBeBZn3CFLluuoOU+htqMTaZVS+BoKTz4mAsTH1KWARIHvjlL+QKBgAn7
ijPgblx7/EzIxLKpHHnqT0gY9de6RTYf3oBEwO0JBnhTPBAQrhij57U2NA76MfKX
d6YQ0Raioi9Fahb+kNGjDfZPQOfIbVMdmQcXv5MeQ6qnw+2bbHt9bbHvTOmvpcLp
/ln/cspQSmc/O2q5UclQNHhTWlejvhG49qbsf9G7AoGAIZNXK7/+YyISVunI17DN
BDurmFabfJu047gyY0mkIZWeg6wWBuVYJhAMhJwLAQErOJuuPH/ar6fL29HOBXhW
fUbG6QeQ2hqxpKp0ZJAtJBtI6b7aqeP62UFw3Sg1RFSTOChz+F/KrGZaac0EecRx
b0mhoJTKjQwtPgTIBZIuS0A=
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIICpjCCAY4CCQDHjcp/tO2FWDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwIBcNMTkwNzI1MTUxMzMzWhgPMjExOTA3MDExNTEzMzNaMBQxEjAQ
BgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
AM/ZZm1oIS3lYow11D/fz+ZvRJ7nxK5Hp3LGQSULxp7OsukY9EEiPPKDtYpGK67i
Yi/MS/miqv8YRA7zOw0qyu3ItS5rJPl4k8ZUSwIPCL7UaC67GMbvlr/NAEyAGREA
PhuKP8QEA+w8kGyIQ2OmExg7KVTWWbC/HaY356Fk54I/2wZGZRiBlGLldiXlO0Jf
Ck7huLyIPMbDtrbKEuodcBa6BgocucNq6ric9sEh2mVRXpSpneLgoo+O1r8QUhF5
RGHdmoF0ub3i4NXvBkzRbIJtxxbR/R3wg2OgskUPs99AmuO28+2euH721Ev4XI8c
BpJkdJhT4q+bkJbBhlcyuucCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAtmoYnUVf
8JJ/tiRlu6XLasMJTVW5jDgcDJ+Da7OTHXSaX83KNShOCoYo/2JXsZtpsQGU6v8H
ofASyDQaebQ5KwCnwph94hZYrnirig+z3cRclvkCb+ohOKME1iAVi4KnkW4UuRgw
wqGKsanx3vHG2stEz3f3b9Az7VpOJq3QJeqklQ45WDs9u/WWWJyTAmechvFS0cP1
tffCfvCbL7FvnWy60gYNH/OCZ4qixFSTR5ljlrzzHvVJaKT2PGMpMn1Vqn4eJX2a
7ds6dkEns6vTS/b0c0tqGCzD/81/xROfOEeslVEyMny52ER0Pxn9hHSbGlKjXoP1
zipDSmONaB51lw==
-----END CERTIFICATE-----
137 changes: 137 additions & 0 deletions integration-tests/matrix_is_tester/fakehs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-

# Copyright 2019 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import atexit
import base64
import json
import os
import random
import ssl
from http.server import BaseHTTPRequestHandler, HTTPServer
from multiprocessing import Process
from urllib.parse import parse_qs, urlparse

shared_fake_hs = None


def token_for_random_user():
"""
Return an OpenID token as would be obtained from the client/server API.
The token will represent a random user account.
"""
num = random.randint(0, 2**32)
user_id = "@user%d:127.0.0.1:4490" % (num,)
return "user:%s" % (base64.b64encode(user_id.encode("UTF-8")).decode("UTF-8"),)


def token_for_user(user_id):
"""
Return an OpenID token as would be obtained from the client/server API.
The token will represent the user_id given.
"""
return "user:%s" % (base64.b64encode(user_id.encode("UTF-8")).decode("UTF-8"),)


def get_shared_fake_hs():
"""
Get the shared fake homeserver object, instantiating it if necessary.
"""
global shared_fake_hs
if shared_fake_hs is None:
shared_fake_hs = FakeHomeserver()
shared_fake_hs.launch()
atexit.register(_destroy_shared)
return shared_fake_hs


def _destroy_shared():
shared_fake_hs.tearDown()


class _FakeHomeserverRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/_matrix/federation/v1/openid/userinfo"):
parsed = urlparse(self.path)
params = parse_qs(parsed.query)

token = params["access_token"][0]

if token.startswith("user:"):
userid = base64.b64decode(token.split(":")[1])
else:
resp = json.dumps(
{
"errcode": "M_UNKNOWN_TOKEN",
"error": "Not a valid token: try again.",
}
)
self.send_response(401)
self.send_header("Content-Length", len(resp))
self.end_headers()

self.wfile.write(resp)

resp = json.dumps({"sub": userid.decode("UTF-8")}).encode("UTF-8")

self.send_response(200)
self.send_header("Content-Length", len(resp))
self.end_headers()

self.wfile.write(resp)
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")

def log_message(self, fmt, *args):
# don't print to stdout: it screws up the test output (and we don't really care)
return


def _run_http_server():
cert_file = os.path.join(os.path.dirname(__file__), "fakehs.pem")

httpd = HTTPServer(("127.0.0.1", 4490), _FakeHomeserverRequestHandler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_file)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
httpd.serve_forever()


class FakeHomeserver:
"""
A class that spawns an HTTP server that looks like a Matrix Homeserver.
Currently just implements the federation OpenID endpoint to validate OpenID tokens.
"""

def launch(self):
self.process = Process(target=_run_http_server)
self.process.start()

def get_addr(self):
"""
Returns a host, port tuple representing the address on which the fake homeserver
is listening for requests.
"""
return ("127.0.0.1", 4490)

def tearDown(self):
self.process.terminate()


if __name__ == "__main__":
fakehs = FakeHomeserver()
fakehs.launch()
Loading
Loading