matrix-axon

Integration testing against a local Synapse

axon’s unit tests and the #[ignore] Postgres tests don’t exercise the real thing: syncing against a live Matrix homeserver, (M3c) re-decrypting UTDs once megolm keys arrive, and downloading an encrypted attachment as plaintext through the media proxy. This doc covers the automated end-to-end test plus the manual walkthrough it’s built from (handy for debugging).

The homeserver is localhost-only and insecure by design (no TLS, sqlite, dev secrets, wide-open rate limits — see docker/synapse/homeserver.yaml). Never expose it or reuse its secrets.

Quick start: the automated script

One command runs the whole prize path — seed an encrypted room with messages and an image attachment, log axon in as a fresh device that can’t decrypt it, then POST it the recovery key, watch the UTDs flip to decrypted, and fetch the original image bytes through axon’s media proxy:

# macOS: a Homebrew Postgres usually holds 5432, so publish the compose one on 5433.
POSTGRES_PORT=5433 scripts/integration-test.sh
# Linux / CI (5432 free): just run it.
scripts/integration-test.sh

What it does, with no manual steps:

  1. Brings up Postgres + Synapse (the integration compose profile) and waits for Synapse to advertise Simplified Sliding Sync (MSC4186) — axon speaks only this, so it’s the make-or-break capability.
  2. Creates a throwaway axon_itest database (dropped + recreated each run) so the test is fully isolated from your dev axon DB.
  3. Registers a unique user and runs the seeder (crates/axon-itest, playing a normal verified “device A”): creates an encrypted room, sends messages and a deterministic PNG attachment, and enables Secure Backup to mint a fresh recovery key.
  4. Starts axon with no account configured (accounts are runtime-provisioned only, ADR 0024) and logs “device B” in via POST /v1/accounts/login with no recovery key; asserts the message lands as a UTD (m.room.encrypted, content IS NULL).
  5. Calls POST /v1/accounts/{id}/recover with the recovery key against the same running device (no restart) and asserts the row flips to a decrypted m.room.message and the UTD backlog drains to zero.
  6. Reads the attachment’s decrypted content.file metadata from the event, requests its MXC URL through GET /v1/media/{account_id}/{server}/{media_id}, and compares the response byte-for-byte with the original PNG.

This script is the consume / join path: the seeder created m.key_backup, and Axon imports it. Axon can also originate megolm backup (ADR 0098) once it is a verified device holding sessions. That is a different success criterion and is not a gate of this script — see Axon-originated megolm backup below.

On success it prints PASS …; on failure it dumps the tail of axon’s log and exits non-zero. It tears the containers + test DB down at the end.

Knobs (all optional, via env):

Var Default Meaning
POSTGRES_PORT 5432 host port for the compose Postgres
SYNAPSE_PORT 8008 host port for Synapse
MESSAGE_COUNT 3 messages the seeder sends
TIMEOUT 90 seconds to wait for each DB condition
KEEP_UP=1 off leave containers + test DB up for inspection

Why it asserts on 1 UTD even though the seeder sends 3: axon archives the events Simplified Sliding Sync surfaces, which is the latest timeline event per room — not the full backlog. The script asserts on the count axon actually archived, then proves that exact set re-decrypts, so it’s robust regardless.

In CI

.github/workflows/integration.yml runs this same script on manual dispatch. The GitHub runner already has docker, docker compose, python3, and curl, and the default ports are free there, so the job is just “checkout → install Rust → run the script”. It’s slower than the lint/test lane because it pulls the Synapse and Postgres images, which is why it’s a separate workflow.

Matrix OAuth QR interoperability (ADR 0097)

The same manual integration workflow exposes five pinned Synapse + MAS lanes: matrix-oauth-api, matrix-oauth-acquire, matrix-oauth-grant, matrix-oauth-browser, and matrix-oauth-unsupported. Choose matrix-oauth-all to run the complete set, or all to run both the re-decryption suite above and every Matrix OAuth lane. The smoke workflow also runs all five Matrix OAuth lanes in a separate job after every push to main and on its nightly schedule. It can be dispatched by hand with a matrix-oauth-* lane as well; the two entry points run the same job, but only the smoke workflow posts the result to the axon-developer room, so use the integration workflow for a quiet ad hoc run.

Locally, run a single lane with:

scripts/matrix-oauth-test.sh api
scripts/matrix-oauth-test.sh acquire
scripts/matrix-oauth-test.sh grant
scripts/matrix-oauth-test.sh browser
scripts/matrix-oauth-test.sh unsupported

The acquisition lane proves Axon becomes cross-signed, receives encryption secrets, decrypts pre-login history, and restores and refreshes its OAuth session after a restart beyond the test token lifetime. Account state and stored history survive a restart without any homeserver traffic, so the refresh counts only once Axon decrypts a message the peer sent while it was down. The separate grant lane proves a trusted Axon account can authorize a fresh SDK device only after explicit authorization-server approval, after which that device is cross-signed and decrypts history using the transferred backup secret. The browser lane (ADR 0117) proves that a user who approves on the authorization server’s page ends with an active account that is not yet verified, that the recovery key then verifies it and decrypts pre-login history, that the session refreshes after a restart beyond the test token lifetime, and that a rejection ends as authorization_denied. The API and unsupported lanes cover failure, rejection, cancellation, timeout, account scoping, capability gating, and secret-safe diagnostics.

The fixture writes runtime protocol material only to memory or a throwaway run directory that is removed by its cleanup trap. Neither workflow uploads Matrix OAuth failure artifacts. See smoke/matrix-oauth/README.md for pinned versions, prerequisites, and the complete secret-handling contract.

Prerequisites (manual walkthrough)

The rest of this doc is the manual procedure the script automates — useful when something breaks and you want to poke at it by hand.

1. Start Synapse

docker compose --profile integration up -d synapse
# wait for healthy:
docker compose --profile integration ps synapse

First boot generates the signing key into the axon-synapse-data volume and runs schema migrations; give it ~10s. Confirm Simplified Sliding Sync (MSC4186) is advertised — axon speaks only this, so it’s the thing that must be on:

curl -fsS http://localhost:8008/_matrix/client/versions | python3 -m json.tool | grep simplified
# -> "org.matrix.simplified_msc3575": true,

2. Register a test user

Shared-secret registration (open registration is off):

docker exec matrix-axon-synapse-1 \
  register_new_matrix_user -c /data/homeserver.yaml \
  -u alice -p hunter2secret --no-admin http://localhost:8008

3. Plaintext smoke test (sync → persist)

This validates the whole real-server loop without E2EE. Seed a room + message over the client-server API:

HS=http://localhost:8008
TOKEN=$(curl -fsS -XPOST $HS/_matrix/client/v3/login -H 'Content-Type: application/json' \
  -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"alice"},"password":"hunter2secret"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
ROOM=$(curl -fsS -XPOST $HS/_matrix/client/v3/createRoom -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"axon-smoke","preset":"private_chat"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['room_id'])")
curl -fsS -XPUT "$HS/_matrix/client/v3/rooms/$ROOM/send/m.room.message/txn-$(date +%s)" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"msgtype":"m.text","body":"hello from the smoke test"}'

Run axon against the local servers with no account configured (accounts are runtime-provisioned only, ADR 0024). Isolate it from .env — axon-server loads .env via dotenvy from the working dir upward, and the project .env may point at other settings you don’t want here. Running the built binary from a throwaway dir avoids that:

cargo build -p axon-server
BIN=$PWD/target/debug/axon-server
mkdir -p /tmp/axon-smoke && cd /tmp/axon-smoke
DATABASE_URL=postgres://axon:axon@127.0.0.1:5433/axon \
AXON_SERVER__PORT=18080 \
AXON_SYNC__DATA_DIR=/tmp/axon-smoke/axon-data/sync \
AXON_SEARCH__INDEX_PATH=/tmp/axon-smoke/axon-data/search \
AXON_MEDIA__CACHE_DIR=/tmp/axon-smoke/axon-data/media \
AXON_SYNC__STORE_KEY=local-smoke-key \
RUST_LOG=info,axon_sync=debug \
  "$BIN" &

In another shell, mint a bearer token and log the account in:

DATABASE_URL=postgres://axon:axon@127.0.0.1:5433/axon "$BIN" token issue --label smoke
# -> prints the token on its own line; export it
TOKEN=<paste the token>
curl -fsS -XPOST http://127.0.0.1:18080/v1/accounts/login \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"username":"@alice:localhost","homeserver_url":"http://localhost:8008","password":"hunter2secret"}'

Watch axon’s log for account logged in and supervised → persisted event … event_type="m.room.message", then confirm the row landed:

docker exec matrix-axon-postgres-1 psql -U axon -d axon -tAc \
  "SELECT event_type, content IS NOT NULL AS decrypted, count(*) FROM events e \
   JOIN accounts a USING(account_id) WHERE a.user_id='@alice:localhost' GROUP BY 1,2;"
# -> m.room.message|t|1

4. E2EE re-decryption test (the M3c prize), by hand

This is what the automated script does; here’s the manual version using the seeder binary as the “device A” that creates encrypted history + a key backup. The seeder still plays device A because this walkthrough is the consume path: Axon starts as an unverified fresh device and must import keys the seeder uploaded. Once Axon is verified, it can originate megolm backup itself (ADR 0098) — that path is the next section, not this script. Set SEED_MEDIA_FILE to also send an encrypted attachment; the automated script uses this to verify the media proxy after recovery.

Build it and seed against a freshly registered user:

cargo build -p axon-itest
SEED_HOMESERVER=http://localhost:8008 \
SEED_USER_ID=@alice:localhost \
SEED_PASSWORD=hunter2secret \
SEED_STORE_DIR=$(mktemp -d) \
SEED_MESSAGE_COUNT=3 \
  ./target/debug/seed
# -> {"user_id":"…","device_id":"…","room_id":"!…:localhost",
#     "recovery_key":"EsT… …","messages":["seed message 0", …]}

Grab the recovery_key and room_id from that JSON, then:

  1. Fresh axon device, log in with no recovery key — run axon as in step 3 but wipe its pinned SDK/search/media paths first, then log in, so it starts unverified and leaves no platform-dir state behind. Historical messages persist as UTDs:
    rm -rf /tmp/axon-smoke/axon-data   # force a fresh, isolated device
    # …run axon (same as step 3, no recovery key involved)…
    curl -fsS -XPOST http://127.0.0.1:18080/v1/accounts/login \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{"username":"@alice:localhost","homeserver_url":"http://localhost:8008","password":"hunter2secret"}'
    # -> {"data":{"account_id":"…", …}} — save the account_id
    docker exec matrix-axon-postgres-1 psql -U axon -d axon -tAc \
      "SELECT event_type, content IS NOT NULL AS decrypted, megolm_session_id IS NOT NULL AS has_session, count(*) \
       FROM events e JOIN accounts a USING(account_id) WHERE a.user_id='@alice:localhost' GROUP BY 1,2,3;"
    # UTDs show: m.room.encrypted | f | t | N
    
  2. POST the recovery key from the seeder to the same running device — no restart needed:
    curl -fsS -XPOST "http://127.0.0.1:18080/v1/accounts/<account_id>/recover" \
      -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
      -d '{"recovery_key":"<recovery_key from the seeder JSON>"}'
    

    Watch for account recovered keys via recovery key (the message is unchanged; structured fields backup_action, selected, decrypted, timed_out are on the same line), then re-decrypted UTD lines, and re-run the query — the m.room.encrypted / decrypted=f count falls as real types with decrypted=t rise. On this consume path backup_action is joined or already_uploading, not enabled.

The recovery key here is a fresh, local one minted by the seeder on this Synapse — not any key you may have used elsewhere.

Axon-originated megolm backup (ADR 0098)

The script and walkthrough above prove Axon can consume a backup another device created. They do not prove Axon can originate one, and they do not decrypt history no device uploaded. ADR 0011 named this gap (“revisit if we need accounts without Secure Backup”); ADR 0098 is that revisit. The recover verb no longer uses the single Recovery::recover call ADR 0026 described; one SecretStore lifetime, then enable/join/export.

recovery().enable_backup() creates a homeserver backup version and a local decryption key. It does not write m.megolm_backup.v1 into 4S; SecretStore::export_secrets() does that. After a successful 4S import, recover auto-enables megolm backup with the existing recovery key when the homeserver has none (backup_action=enabled). If a backup already exists, recover joins it (joined / already_uploading / export-only) and never 409s that join. POST /v1/accounts/{id}/backup/enable is the dedicated verb after SAS (gossip does not populate 4S).

Recover 200 means 4S import succeeded, not “history keys downloaded.” Read data.backup_action and data.redecrypt (selected, decrypted, timed_out). verified: true with 4S recovery_state: enabled and backup.exists_on_server: false is not “history keys imported.”

What originating backup will not decrypt. Sessions no device still holding those inbound keys has uploaded stay UTD. Pre-Axon history, and history from before this device enabled backup, is in that set. That is success, not a sweep bug.

Optional hand-test (4S without megolm backup; not a CI gate):

  1. An account that has 4S / cross-signing and no m.key_backup. After a CS-only recover, GET /v1/accounts/{id} shows backup.exists_on_server: false (GET uses a cache that may lag another client’s create).
  2. POST …/recover with the existing recovery key. Expect 200 backup_action=enabled (or export_pending / failed, then retry POST …/backup/enable with the same key). Do not mint a new recovery key.
  3. Confirm this_device_uploading: true once enable finishes.
  4. Send a message in an encrypted room, logout, login as a fresh Axon device, recover with the same key.
  5. That post-enable message decrypts. Earlier UTDs staying UTD is success.

TUI: /backup enable [user] (masked key; empty Enter kicks upload) and /status for the snapshot. Web: account-card badge and Enable backup on /accounts. There is no account.backup WebSocket yet (#292); GET is the reconnect source of truth.

Teardown

docker compose --profile integration down            # stop synapse (keeps data)
docker compose --profile integration down -v synapse # also wipe the volume