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.
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:
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.axon_itest database (dropped + recreated each run) so the
test is fully isolated from your dev axon DB.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.POST /v1/accounts/login with
no recovery key; asserts the message lands as a UTD (m.room.encrypted,
content IS NULL).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.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.
.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.
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.
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.
docker-compose.yml.integration compose profile, so it does not
start with a plain docker compose up.POSTGRES_PORT in .env). All
DATABASE_URLs below use 5433 — adjust if your .env differs.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,
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
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
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:
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
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.
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):
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).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.this_device_uploading: true once enable finishes.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.
docker compose --profile integration down # stop synapse (keeps data)
docker compose --profile integration down -v synapse # also wipe the volume