atagen

malice

public
Star0
Watch0
e13d9c5d4f3d1442fe18006d7b3683bbcdb7942f
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git

Blame: crates/malice-server/tests/integration.rs

973f8fbdaf80atagen2026-09-28 09:10 UTC1
use std::{
973f8fbdaf80atagen2026-09-28 09:10 UTC2
    net::TcpListener,
973f8fbdaf80atagen2026-09-28 09:10 UTC3
    path::PathBuf,
973f8fbdaf80atagen2026-09-28 09:10 UTC4
    process::{Child, Command, Stdio},
973f8fbdaf80atagen2026-09-28 09:10 UTC5
    sync::atomic::{AtomicBool, Ordering},
973f8fbdaf80atagen2026-09-28 09:10 UTC6
    time::Duration,
973f8fbdaf80atagen2026-09-28 09:10 UTC7
};
973f8fbdaf80atagen2026-09-28 09:10 UTC8
973f8fbdaf80atagen2026-09-28 09:10 UTC9
use matrix_sdk::test_utils::mocks::MatrixMockServer;
973f8fbdaf80atagen2026-09-28 09:10 UTC10
use reqwest::{Client, StatusCode, header};
973f8fbdaf80atagen2026-09-28 09:10 UTC11
use serde_json::{Value, json};
973f8fbdaf80atagen2026-09-28 09:10 UTC12
use tempfile::TempDir;
973f8fbdaf80atagen2026-09-28 09:10 UTC13
use wiremock::{
973f8fbdaf80atagen2026-09-28 09:10 UTC14
    Mock, ResponseTemplate,
973f8fbdaf80atagen2026-09-28 09:10 UTC15
    matchers::{method, path_regex},
973f8fbdaf80atagen2026-09-28 09:10 UTC16
};
973f8fbdaf80atagen2026-09-28 09:10 UTC17
973f8fbdaf80atagen2026-09-28 09:10 UTC18
struct Server(Child);
973f8fbdaf80atagen2026-09-28 09:10 UTC19
973f8fbdaf80atagen2026-09-28 09:10 UTC20
impl Drop for Server {
973f8fbdaf80atagen2026-09-28 09:10 UTC21
    fn drop(&mut self) {
973f8fbdaf80atagen2026-09-28 09:10 UTC22
        let _ = self.0.kill();
973f8fbdaf80atagen2026-09-28 09:10 UTC23
        let _ = self.0.wait();
973f8fbdaf80atagen2026-09-28 09:10 UTC24
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC25
}
973f8fbdaf80atagen2026-09-28 09:10 UTC26
973f8fbdaf80atagen2026-09-28 09:10 UTC27
fn server_binary() -> PathBuf {
973f8fbdaf80atagen2026-09-28 09:10 UTC28
    option_env!("CARGO_BIN_EXE_malice")
973f8fbdaf80atagen2026-09-28 09:10 UTC29
        .map(PathBuf::from)
973f8fbdaf80atagen2026-09-28 09:10 UTC30
        .unwrap_or_else(|| {
973f8fbdaf80atagen2026-09-28 09:10 UTC31
            std::env::current_exe()
973f8fbdaf80atagen2026-09-28 09:10 UTC32
                .expect("integration test executable")
973f8fbdaf80atagen2026-09-28 09:10 UTC33
                .parent()
973f8fbdaf80atagen2026-09-28 09:10 UTC34
                .expect("cargo deps directory")
973f8fbdaf80atagen2026-09-28 09:10 UTC35
                .parent()
973f8fbdaf80atagen2026-09-28 09:10 UTC36
                .expect("cargo target directory")
973f8fbdaf80atagen2026-09-28 09:10 UTC37
                .join("malice")
973f8fbdaf80atagen2026-09-28 09:10 UTC38
        })
973f8fbdaf80atagen2026-09-28 09:10 UTC39
}
973f8fbdaf80atagen2026-09-28 09:10 UTC40
973f8fbdaf80atagen2026-09-28 09:10 UTC41
async fn start(port: u16, data_dir: &TempDir, client: &Client) -> Server {
973f8fbdaf80atagen2026-09-28 09:10 UTC42
    let child = Command::new(server_binary())
973f8fbdaf80atagen2026-09-28 09:10 UTC43
        .env("MALICE_BIND", format!("127.0.0.1:{port}"))
973f8fbdaf80atagen2026-09-28 09:10 UTC44
        .env("MALICE_DATA_DIR", data_dir.path())
973f8fbdaf80atagen2026-09-28 09:10 UTC45
        .env("MALICE_SESSION_COOKIE_SECURE", "false")
973f8fbdaf80atagen2026-09-28 09:10 UTC46
        .env("MALICE_ALLOW_HTTP_HOMESERVER", "true")
973f8fbdaf80atagen2026-09-28 09:10 UTC47
        .stdout(Stdio::null())
973f8fbdaf80atagen2026-09-28 09:10 UTC48
        .stderr(Stdio::inherit())
973f8fbdaf80atagen2026-09-28 09:10 UTC49
        .spawn()
973f8fbdaf80atagen2026-09-28 09:10 UTC50
        .expect("start Malice backend");
973f8fbdaf80atagen2026-09-28 09:10 UTC51
    let mut server = Server(child);
973f8fbdaf80atagen2026-09-28 09:10 UTC52
    tokio::time::timeout(Duration::from_secs(30), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC53
        loop {
973f8fbdaf80atagen2026-09-28 09:10 UTC54
            if let Some(status) = server.0.try_wait().expect("poll backend") {
973f8fbdaf80atagen2026-09-28 09:10 UTC55
                panic!("Malice exited before health check: {status}");
973f8fbdaf80atagen2026-09-28 09:10 UTC56
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC57
            if client
973f8fbdaf80atagen2026-09-28 09:10 UTC58
                .get(format!("http://127.0.0.1:{port}/api/health"))
973f8fbdaf80atagen2026-09-28 09:10 UTC59
                .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC60
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC61
                .is_ok_and(|response| response.status().is_success())
973f8fbdaf80atagen2026-09-28 09:10 UTC62
            {
973f8fbdaf80atagen2026-09-28 09:10 UTC63
                break;
973f8fbdaf80atagen2026-09-28 09:10 UTC64
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC65
            tokio::time::sleep(Duration::from_millis(100)).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC66
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC67
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC68
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC69
    .expect("Malice did not start");
973f8fbdaf80atagen2026-09-28 09:10 UTC70
    server
973f8fbdaf80atagen2026-09-28 09:10 UTC71
}
973f8fbdaf80atagen2026-09-28 09:10 UTC72
973f8fbdaf80atagen2026-09-28 09:10 UTC73
fn unused_port() -> u16 {
973f8fbdaf80atagen2026-09-28 09:10 UTC74
    TcpListener::bind("127.0.0.1:0")
973f8fbdaf80atagen2026-09-28 09:10 UTC75
        .expect("bind ephemeral port")
973f8fbdaf80atagen2026-09-28 09:10 UTC76
        .local_addr()
973f8fbdaf80atagen2026-09-28 09:10 UTC77
        .expect("read ephemeral port")
973f8fbdaf80atagen2026-09-28 09:10 UTC78
        .port()
973f8fbdaf80atagen2026-09-28 09:10 UTC79
}
973f8fbdaf80atagen2026-09-28 09:10 UTC80
973f8fbdaf80atagen2026-09-28 09:10 UTC81
#[tokio::test]
973f8fbdaf80atagen2026-09-28 09:10 UTC82
async fn login_isolated_cookie_survives_restart_and_logout_revokes_it() {
973f8fbdaf80atagen2026-09-28 09:10 UTC83
    let matrix = MatrixMockServer::new().await;
973f8fbdaf80atagen2026-09-28 09:10 UTC84
    matrix.mock_versions().ok().mount().await;
973f8fbdaf80atagen2026-09-28 09:10 UTC85
    matrix.mock_login().ok().expect(1).mount().await;
973f8fbdaf80atagen2026-09-28 09:10 UTC86
    let first_room = AtomicBool::new(false);
973f8fbdaf80atagen2026-09-28 09:10 UTC87
    Mock::given(method("POST"))
973f8fbdaf80atagen2026-09-28 09:10 UTC88
        .and(path_regex("/sync$"))
973f8fbdaf80atagen2026-09-28 09:10 UTC89
        .respond_with(move |request: &wiremock::Request| {
973f8fbdaf80atagen2026-09-28 09:10 UTC90
            let body: Value = serde_json::from_slice(&request.body).unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC91
            let rooms =
973f8fbdaf80atagen2026-09-28 09:10 UTC92
                if body["conn_id"] == "room-list" && !first_room.swap(true, Ordering::Relaxed) {
973f8fbdaf80atagen2026-09-28 09:10 UTC93
                    json!({"!malice:localhost": {
973f8fbdaf80atagen2026-09-28 09:10 UTC94
                        "initial": true,
973f8fbdaf80atagen2026-09-28 09:10 UTC95
                        "name": "Malice test room",
973f8fbdaf80atagen2026-09-28 09:10 UTC96
                        "joined_count": 1,
973f8fbdaf80atagen2026-09-28 09:10 UTC97
                        "required_state": [{
973f8fbdaf80atagen2026-09-28 09:10 UTC98
                            "type": "m.room.member",
973f8fbdaf80atagen2026-09-28 09:10 UTC99
                            "state_key": "@cheeky_monkey:matrix.org",
973f8fbdaf80atagen2026-09-28 09:10 UTC100
                            "sender": "@cheeky_monkey:matrix.org",
973f8fbdaf80atagen2026-09-28 09:10 UTC101
                            "event_id": "$join:localhost",
973f8fbdaf80atagen2026-09-28 09:10 UTC102
                            "origin_server_ts": 1,
973f8fbdaf80atagen2026-09-28 09:10 UTC103
                            "content": {"membership": "join"}
973f8fbdaf80atagen2026-09-28 09:10 UTC104
                        }],
973f8fbdaf80atagen2026-09-28 09:10 UTC105
                        "timeline": [{
973f8fbdaf80atagen2026-09-28 09:10 UTC106
                            "type": "m.room.message",
973f8fbdaf80atagen2026-09-28 09:10 UTC107
                            "sender": "@cheeky_monkey:matrix.org",
973f8fbdaf80atagen2026-09-28 09:10 UTC108
                            "event_id": "$recent:localhost",
973f8fbdaf80atagen2026-09-28 09:10 UTC109
                            "origin_server_ts": 2,
973f8fbdaf80atagen2026-09-28 09:10 UTC110
                            "content": {"msgtype":"m.text", "body":"recent channel message"}
973f8fbdaf80atagen2026-09-28 09:10 UTC111
                        },{
973f8fbdaf80atagen2026-09-28 09:10 UTC112
                            "type": "m.room.message",
973f8fbdaf80atagen2026-09-28 09:10 UTC113
                            "sender": "@cheeky_monkey:matrix.org",
973f8fbdaf80atagen2026-09-28 09:10 UTC114
                            "event_id": "$image:localhost",
973f8fbdaf80atagen2026-09-28 09:10 UTC115
                            "origin_server_ts": 3,
973f8fbdaf80atagen2026-09-28 09:10 UTC116
                            "content": {
973f8fbdaf80atagen2026-09-28 09:10 UTC117
                                "msgtype": "m.image",
973f8fbdaf80atagen2026-09-28 09:10 UTC118
                                "body": "image.png",
973f8fbdaf80atagen2026-09-28 09:10 UTC119
                                "url": "mxc://localhost/avatar",
973f8fbdaf80atagen2026-09-28 09:10 UTC120
                                "info": {"mimetype": "image/png", "size": 13}
973f8fbdaf80atagen2026-09-28 09:10 UTC121
                            }
973f8fbdaf80atagen2026-09-28 09:10 UTC122
                        }],
973f8fbdaf80atagen2026-09-28 09:10 UTC123
                        "prev_batch": "older-page"
973f8fbdaf80atagen2026-09-28 09:10 UTC124
                    }})
973f8fbdaf80atagen2026-09-28 09:10 UTC125
                } else {
973f8fbdaf80atagen2026-09-28 09:10 UTC126
                    json!({})
973f8fbdaf80atagen2026-09-28 09:10 UTC127
                };
973f8fbdaf80atagen2026-09-28 09:10 UTC128
            ResponseTemplate::new(200)
973f8fbdaf80atagen2026-09-28 09:10 UTC129
                .set_body_json(json!({"pos":"1","lists":{},"rooms":rooms,"extensions":{}}))
973f8fbdaf80atagen2026-09-28 09:10 UTC130
                .set_delay(Duration::from_millis(100))
973f8fbdaf80atagen2026-09-28 09:10 UTC131
        })
973f8fbdaf80atagen2026-09-28 09:10 UTC132
        .mount(matrix.server())
973f8fbdaf80atagen2026-09-28 09:10 UTC133
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC134
    Mock::given(method("GET"))
973f8fbdaf80atagen2026-09-28 09:10 UTC135
        .and(path_regex("/rooms/.*/messages$"))
973f8fbdaf80atagen2026-09-28 09:10 UTC136
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
973f8fbdaf80atagen2026-09-28 09:10 UTC137
            "start": "older-page",
973f8fbdaf80atagen2026-09-28 09:10 UTC138
            "chunk": [{
973f8fbdaf80atagen2026-09-28 09:10 UTC139
                "type": "m.room.message",
973f8fbdaf80atagen2026-09-28 09:10 UTC140
                "room_id": "!malice:localhost",
973f8fbdaf80atagen2026-09-28 09:10 UTC141
                "sender": "@cheeky_monkey:matrix.org",
973f8fbdaf80atagen2026-09-28 09:10 UTC142
                "event_id": "$older:localhost",
973f8fbdaf80atagen2026-09-28 09:10 UTC143
                "origin_server_ts": 1,
973f8fbdaf80atagen2026-09-28 09:10 UTC144
                "content": {"msgtype":"m.text", "body":"older channel message"}
973f8fbdaf80atagen2026-09-28 09:10 UTC145
            }]
973f8fbdaf80atagen2026-09-28 09:10 UTC146
        })))
973f8fbdaf80atagen2026-09-28 09:10 UTC147
        .mount(matrix.server())
973f8fbdaf80atagen2026-09-28 09:10 UTC148
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC149
    matrix
973f8fbdaf80atagen2026-09-28 09:10 UTC150
        .mock_room_state_encryption()
973f8fbdaf80atagen2026-09-28 09:10 UTC151
        .expect_access_token("abc123")
973f8fbdaf80atagen2026-09-28 09:10 UTC152
        .plain()
973f8fbdaf80atagen2026-09-28 09:10 UTC153
        .mount()
973f8fbdaf80atagen2026-09-28 09:10 UTC154
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC155
    matrix
973f8fbdaf80atagen2026-09-28 09:10 UTC156
        .mock_room_send()
973f8fbdaf80atagen2026-09-28 09:10 UTC157
        .expect_access_token("abc123")
973f8fbdaf80atagen2026-09-28 09:10 UTC158
        .body_matches_partial_json(json!({"body":"backend room smoke"}))
973f8fbdaf80atagen2026-09-28 09:10 UTC159
        .ok(matrix_sdk::ruma::event_id!("$sent:localhost"))
973f8fbdaf80atagen2026-09-28 09:10 UTC160
        .expect(1)
973f8fbdaf80atagen2026-09-28 09:10 UTC161
        .mount()
973f8fbdaf80atagen2026-09-28 09:10 UTC162
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC163
973f8fbdaf80atagen2026-09-28 09:10 UTC164
    Mock::given(method("GET"))
973f8fbdaf80atagen2026-09-28 09:10 UTC165
        .and(path_regex("/download/[^/]+/avatar$"))
973f8fbdaf80atagen2026-09-28 09:10 UTC166
        .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\nimage".to_vec()))
973f8fbdaf80atagen2026-09-28 09:10 UTC167
        .mount(matrix.server())
973f8fbdaf80atagen2026-09-28 09:10 UTC168
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC169
    Mock::given(method("GET"))
973f8fbdaf80atagen2026-09-28 09:10 UTC170
        .and(path_regex("/download/[^/]+/svg$"))
973f8fbdaf80atagen2026-09-28 09:10 UTC171
        .respond_with(
973f8fbdaf80atagen2026-09-28 09:10 UTC172
            ResponseTemplate::new(200).set_body_bytes(b"<svg onload='alert(1)'/>".to_vec()),
973f8fbdaf80atagen2026-09-28 09:10 UTC173
        )
973f8fbdaf80atagen2026-09-28 09:10 UTC174
        .mount(matrix.server())
973f8fbdaf80atagen2026-09-28 09:10 UTC175
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC176
973f8fbdaf80atagen2026-09-28 09:10 UTC177
    let data_dir = tempfile::tempdir().expect("temporary user stores");
973f8fbdaf80atagen2026-09-28 09:10 UTC178
    let client = Client::new();
973f8fbdaf80atagen2026-09-28 09:10 UTC179
    let port = unused_port();
973f8fbdaf80atagen2026-09-28 09:10 UTC180
    let base = format!("http://127.0.0.1:{port}");
973f8fbdaf80atagen2026-09-28 09:10 UTC181
    let server = start(port, &data_dir, &client).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC182
973f8fbdaf80atagen2026-09-28 09:10 UTC183
    let guest: Value = client
973f8fbdaf80atagen2026-09-28 09:10 UTC184
        .get(format!("{base}/api/session"))
973f8fbdaf80atagen2026-09-28 09:10 UTC185
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC186
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC187
        .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC188
        .json()
973f8fbdaf80atagen2026-09-28 09:10 UTC189
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC190
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC191
    assert_eq!(guest["user_id"], Value::Null);
973f8fbdaf80atagen2026-09-28 09:10 UTC192
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC193
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC194
            .get(format!("{base}/api/rooms"))
973f8fbdaf80atagen2026-09-28 09:10 UTC195
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC196
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC197
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC198
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC199
        StatusCode::UNAUTHORIZED
973f8fbdaf80atagen2026-09-28 09:10 UTC200
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC201
973f8fbdaf80atagen2026-09-28 09:10 UTC202
    let cross_origin = client
973f8fbdaf80atagen2026-09-28 09:10 UTC203
        .post(format!("{base}/api/login"))
973f8fbdaf80atagen2026-09-28 09:10 UTC204
        .header(header::ORIGIN, "https://attacker.example")
973f8fbdaf80atagen2026-09-28 09:10 UTC205
        .json(&json!({
973f8fbdaf80atagen2026-09-28 09:10 UTC206
            "homeserver": matrix.uri(),
973f8fbdaf80atagen2026-09-28 09:10 UTC207
            "username": "alice",
973f8fbdaf80atagen2026-09-28 09:10 UTC208
            "password": "test-password"
973f8fbdaf80atagen2026-09-28 09:10 UTC209
        }))
973f8fbdaf80atagen2026-09-28 09:10 UTC210
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC211
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC212
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC213
    assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN);
973f8fbdaf80atagen2026-09-28 09:10 UTC214
973f8fbdaf80atagen2026-09-28 09:10 UTC215
    let cross_port = client
973f8fbdaf80atagen2026-09-28 09:10 UTC216
        .post(format!("{base}/api/login"))
973f8fbdaf80atagen2026-09-28 09:10 UTC217
        .header(header::ORIGIN, format!("http://127.0.0.1:{}", port + 1))
973f8fbdaf80atagen2026-09-28 09:10 UTC218
        .json(&json!({
973f8fbdaf80atagen2026-09-28 09:10 UTC219
            "homeserver": matrix.uri(),
973f8fbdaf80atagen2026-09-28 09:10 UTC220
            "username": "alice",
973f8fbdaf80atagen2026-09-28 09:10 UTC221
            "password": "test-password"
973f8fbdaf80atagen2026-09-28 09:10 UTC222
        }))
973f8fbdaf80atagen2026-09-28 09:10 UTC223
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC224
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC225
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC226
    assert_eq!(cross_port.status(), StatusCode::FORBIDDEN);
973f8fbdaf80atagen2026-09-28 09:10 UTC227
973f8fbdaf80atagen2026-09-28 09:10 UTC228
    let login = client
973f8fbdaf80atagen2026-09-28 09:10 UTC229
        .post(format!("{base}/api/login"))
973f8fbdaf80atagen2026-09-28 09:10 UTC230
        .header(header::ORIGIN, &base)
973f8fbdaf80atagen2026-09-28 09:10 UTC231
        .json(&json!({
973f8fbdaf80atagen2026-09-28 09:10 UTC232
            "homeserver": matrix.uri(),
973f8fbdaf80atagen2026-09-28 09:10 UTC233
            "username": "alice",
973f8fbdaf80atagen2026-09-28 09:10 UTC234
            "password": "test-password"
973f8fbdaf80atagen2026-09-28 09:10 UTC235
        }))
973f8fbdaf80atagen2026-09-28 09:10 UTC236
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC237
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC238
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC239
    assert!(
973f8fbdaf80atagen2026-09-28 09:10 UTC240
        login.status().is_success(),
973f8fbdaf80atagen2026-09-28 09:10 UTC241
        "login returned {}",
973f8fbdaf80atagen2026-09-28 09:10 UTC242
        login.status()
973f8fbdaf80atagen2026-09-28 09:10 UTC243
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC244
    let cookie = login
973f8fbdaf80atagen2026-09-28 09:10 UTC245
        .headers()
973f8fbdaf80atagen2026-09-28 09:10 UTC246
        .get(header::SET_COOKIE)
973f8fbdaf80atagen2026-09-28 09:10 UTC247
        .expect("opaque session cookie")
973f8fbdaf80atagen2026-09-28 09:10 UTC248
        .to_str()
973f8fbdaf80atagen2026-09-28 09:10 UTC249
        .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC250
        .split(';')
973f8fbdaf80atagen2026-09-28 09:10 UTC251
        .next()
973f8fbdaf80atagen2026-09-28 09:10 UTC252
        .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC253
        .to_owned();
973f8fbdaf80atagen2026-09-28 09:10 UTC254
    let logged_in: Value = login.json().await.unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC255
    assert!(logged_in["user_id"].as_str().is_some());
973f8fbdaf80atagen2026-09-28 09:10 UTC256
    #[cfg(unix)]
973f8fbdaf80atagen2026-09-28 09:10 UTC257
    {
973f8fbdaf80atagen2026-09-28 09:10 UTC258
        use std::os::unix::fs::PermissionsExt;
973f8fbdaf80atagen2026-09-28 09:10 UTC259
        let sid = cookie.split_once('=').unwrap().1;
973f8fbdaf80atagen2026-09-28 09:10 UTC260
        let mode = |path: std::path::PathBuf| {
973f8fbdaf80atagen2026-09-28 09:10 UTC261
            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
973f8fbdaf80atagen2026-09-28 09:10 UTC262
        };
973f8fbdaf80atagen2026-09-28 09:10 UTC263
        assert_eq!(mode(data_dir.path().join("master.key")), 0o600);
973f8fbdaf80atagen2026-09-28 09:10 UTC264
        assert_eq!(mode(data_dir.path().join("sessions")), 0o700);
973f8fbdaf80atagen2026-09-28 09:10 UTC265
        assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC266
            mode(data_dir.path().join("sessions").join(format!("{sid}.bin"))),
973f8fbdaf80atagen2026-09-28 09:10 UTC267
            0o600
973f8fbdaf80atagen2026-09-28 09:10 UTC268
        );
973f8fbdaf80atagen2026-09-28 09:10 UTC269
    }
973f8fbdaf80atagen2026-09-28 09:10 UTC270
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC271
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC272
            .get(format!("{base}/api/rooms"))
973f8fbdaf80atagen2026-09-28 09:10 UTC273
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC274
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC275
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC276
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC277
        StatusCode::UNAUTHORIZED,
973f8fbdaf80atagen2026-09-28 09:10 UTC278
        "one browser must not inherit another browser's Matrix session"
973f8fbdaf80atagen2026-09-28 09:10 UTC279
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC280
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC281
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC282
            .get(format!("{base}/api/media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC283
            .query(&[("mxc", "mxc://localhost/avatar")])
973f8fbdaf80atagen2026-09-28 09:10 UTC284
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC285
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC286
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC287
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC288
        StatusCode::UNAUTHORIZED,
973f8fbdaf80atagen2026-09-28 09:10 UTC289
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC290
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC291
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC292
            .get(format!("{base}/api/media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC293
            .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC294
            .query(&[("mxc", "not-a-mxc")])
973f8fbdaf80atagen2026-09-28 09:10 UTC295
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC296
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC297
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC298
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC299
        StatusCode::BAD_REQUEST,
973f8fbdaf80atagen2026-09-28 09:10 UTC300
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC301
    let avatar = client
973f8fbdaf80atagen2026-09-28 09:10 UTC302
        .get(format!("{base}/api/media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC303
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC304
        .query(&[("mxc", "mxc://localhost/avatar")])
973f8fbdaf80atagen2026-09-28 09:10 UTC305
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC306
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC307
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC308
    assert_eq!(avatar.status(), StatusCode::OK);
973f8fbdaf80atagen2026-09-28 09:10 UTC309
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC310
        avatar.headers().get(header::CONTENT_TYPE).unwrap(),
973f8fbdaf80atagen2026-09-28 09:10 UTC311
        "image/png"
973f8fbdaf80atagen2026-09-28 09:10 UTC312
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC313
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC314
        avatar.bytes().await.unwrap().as_ref(),
973f8fbdaf80atagen2026-09-28 09:10 UTC315
        b"\x89PNG\r\n\x1a\nimage"
973f8fbdaf80atagen2026-09-28 09:10 UTC316
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC317
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC318
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC319
            .get(format!("{base}/api/media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC320
            .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC321
            .query(&[("mxc", "mxc://localhost/svg")])
973f8fbdaf80atagen2026-09-28 09:10 UTC322
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC323
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC324
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC325
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC326
        StatusCode::UNSUPPORTED_MEDIA_TYPE,
973f8fbdaf80atagen2026-09-28 09:10 UTC327
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC328
    let rooms = client
973f8fbdaf80atagen2026-09-28 09:10 UTC329
        .get(format!("{base}/api/rooms"))
973f8fbdaf80atagen2026-09-28 09:10 UTC330
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC331
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC332
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC333
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC334
    assert!(rooms.status().is_success());
973f8fbdaf80atagen2026-09-28 09:10 UTC335
    let rooms: Value = rooms.json().await.unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC336
    assert!(rooms["rooms"].is_array());
973f8fbdaf80atagen2026-09-28 09:10 UTC337
    tokio::time::timeout(Duration::from_secs(5), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC338
        loop {
973f8fbdaf80atagen2026-09-28 09:10 UTC339
            let response: Value = client
973f8fbdaf80atagen2026-09-28 09:10 UTC340
                .get(format!("{base}/api/rooms"))
973f8fbdaf80atagen2026-09-28 09:10 UTC341
                .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC342
                .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC343
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC344
                .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC345
                .json()
973f8fbdaf80atagen2026-09-28 09:10 UTC346
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC347
                .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC348
            if response["rooms"]
973f8fbdaf80atagen2026-09-28 09:10 UTC349
                .as_array()
973f8fbdaf80atagen2026-09-28 09:10 UTC350
                .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC351
                .iter()
973f8fbdaf80atagen2026-09-28 09:10 UTC352
                .any(|room| room["room_id"] == "!malice:localhost")
973f8fbdaf80atagen2026-09-28 09:10 UTC353
            {
973f8fbdaf80atagen2026-09-28 09:10 UTC354
                break;
973f8fbdaf80atagen2026-09-28 09:10 UTC355
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC356
            tokio::time::sleep(Duration::from_millis(50)).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC357
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC358
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC359
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC360
    .expect("joined room appeared in live room list");
973f8fbdaf80atagen2026-09-28 09:10 UTC361
    let open = client
973f8fbdaf80atagen2026-09-28 09:10 UTC362
        .post(format!("{base}/api/rooms/open"))
973f8fbdaf80atagen2026-09-28 09:10 UTC363
        .header(header::ORIGIN, &base)
973f8fbdaf80atagen2026-09-28 09:10 UTC364
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC365
        .json(&json!({"room_id":"!malice:localhost"}))
973f8fbdaf80atagen2026-09-28 09:10 UTC366
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC367
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC368
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC369
    assert_eq!(open.status(), StatusCode::NO_CONTENT);
973f8fbdaf80atagen2026-09-28 09:10 UTC370
    let media_id = tokio::time::timeout(Duration::from_secs(10), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC371
        loop {
973f8fbdaf80atagen2026-09-28 09:10 UTC372
            let mut stream = client
973f8fbdaf80atagen2026-09-28 09:10 UTC373
                .get(format!("{base}/api/events"))
973f8fbdaf80atagen2026-09-28 09:10 UTC374
                .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC375
                .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC376
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC377
                .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC378
            let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
973f8fbdaf80atagen2026-09-28 09:10 UTC379
            let text = std::str::from_utf8(&frame).unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC380
            let payload = text
973f8fbdaf80atagen2026-09-28 09:10 UTC381
                .lines()
973f8fbdaf80atagen2026-09-28 09:10 UTC382
                .find_map(|line| line.strip_prefix("data: "))
973f8fbdaf80atagen2026-09-28 09:10 UTC383
                .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC384
            let snapshot: Value = serde_json::from_str(payload).unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC385
            if let Some(id) = snapshot["timelines"]["!malice:localhost"]
973f8fbdaf80atagen2026-09-28 09:10 UTC386
                .as_array()
973f8fbdaf80atagen2026-09-28 09:10 UTC387
                .and_then(|items| {
973f8fbdaf80atagen2026-09-28 09:10 UTC388
                    items.iter().find_map(|item| {
973f8fbdaf80atagen2026-09-28 09:10 UTC389
                        item["Event"]["content"]["Message"]["media"]["id"].as_str()
973f8fbdaf80atagen2026-09-28 09:10 UTC390
                    })
973f8fbdaf80atagen2026-09-28 09:10 UTC391
                })
973f8fbdaf80atagen2026-09-28 09:10 UTC392
            {
973f8fbdaf80atagen2026-09-28 09:10 UTC393
                break id.to_owned();
973f8fbdaf80atagen2026-09-28 09:10 UTC394
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC395
            tokio::time::sleep(Duration::from_millis(50)).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC396
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC397
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC398
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC399
    .expect("opened room registered image attachment");
973f8fbdaf80atagen2026-09-28 09:10 UTC400
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC401
        client
973f8fbdaf80atagen2026-09-28 09:10 UTC402
            .get(format!("{base}/api/message-media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC403
            .query(&[("media_id", media_id.as_str()), ("size", "Full")])
973f8fbdaf80atagen2026-09-28 09:10 UTC404
            .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC405
            .await
973f8fbdaf80atagen2026-09-28 09:10 UTC406
            .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC407
            .status(),
973f8fbdaf80atagen2026-09-28 09:10 UTC408
        StatusCode::UNAUTHORIZED,
973f8fbdaf80atagen2026-09-28 09:10 UTC409
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC410
    let attachment = client
973f8fbdaf80atagen2026-09-28 09:10 UTC411
        .get(format!("{base}/api/message-media"))
973f8fbdaf80atagen2026-09-28 09:10 UTC412
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC413
        .query(&[("media_id", media_id.as_str()), ("size", "Full")])
973f8fbdaf80atagen2026-09-28 09:10 UTC414
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC415
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC416
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC417
    assert_eq!(attachment.status(), StatusCode::OK);
973f8fbdaf80atagen2026-09-28 09:10 UTC418
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC419
        attachment.headers().get(header::CONTENT_TYPE).unwrap(),
973f8fbdaf80atagen2026-09-28 09:10 UTC420
        "image/png"
973f8fbdaf80atagen2026-09-28 09:10 UTC421
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC422
    assert_eq!(
973f8fbdaf80atagen2026-09-28 09:10 UTC423
        attachment.bytes().await.unwrap().as_ref(),
973f8fbdaf80atagen2026-09-28 09:10 UTC424
        b"\x89PNG\r\n\x1a\nimage"
973f8fbdaf80atagen2026-09-28 09:10 UTC425
    );
973f8fbdaf80atagen2026-09-28 09:10 UTC426
    let sent = client
973f8fbdaf80atagen2026-09-28 09:10 UTC427
        .post(format!("{base}/api/rooms/send"))
973f8fbdaf80atagen2026-09-28 09:10 UTC428
        .header(header::ORIGIN, &base)
973f8fbdaf80atagen2026-09-28 09:10 UTC429
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC430
        .json(&json!({"room_id":"!malice:localhost", "body":"backend room smoke"}))
973f8fbdaf80atagen2026-09-28 09:10 UTC431
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC432
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC433
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC434
    assert_eq!(sent.status(), StatusCode::NO_CONTENT);
973f8fbdaf80atagen2026-09-28 09:10 UTC435
    tokio::time::timeout(Duration::from_secs(5), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC436
        loop {
973f8fbdaf80atagen2026-09-28 09:10 UTC437
            if matrix
973f8fbdaf80atagen2026-09-28 09:10 UTC438
                .received_requests()
973f8fbdaf80atagen2026-09-28 09:10 UTC439
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC440
                .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC441
                .iter()
973f8fbdaf80atagen2026-09-28 09:10 UTC442
                .any(|request| request.url.path().contains("/send/m.room.message/"))
973f8fbdaf80atagen2026-09-28 09:10 UTC443
            {
973f8fbdaf80atagen2026-09-28 09:10 UTC444
                break;
973f8fbdaf80atagen2026-09-28 09:10 UTC445
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC446
            tokio::time::sleep(Duration::from_millis(50)).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC447
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC448
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC449
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC450
    .expect("message reached Matrix send endpoint");
973f8fbdaf80atagen2026-09-28 09:10 UTC451
    tokio::time::timeout(Duration::from_secs(15), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC452
        loop {
973f8fbdaf80atagen2026-09-28 09:10 UTC453
            let mut stream = client
973f8fbdaf80atagen2026-09-28 09:10 UTC454
                .get(format!("{base}/api/events"))
973f8fbdaf80atagen2026-09-28 09:10 UTC455
                .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC456
                .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC457
                .await
973f8fbdaf80atagen2026-09-28 09:10 UTC458
                .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC459
            let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
973f8fbdaf80atagen2026-09-28 09:10 UTC460
            let text = std::str::from_utf8(&frame).unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC461
            let payload = text
973f8fbdaf80atagen2026-09-28 09:10 UTC462
                .lines()
973f8fbdaf80atagen2026-09-28 09:10 UTC463
                .find_map(|line| line.strip_prefix("data: "))
973f8fbdaf80atagen2026-09-28 09:10 UTC464
                .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC465
            let snapshot: Value = serde_json::from_str(payload).unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC466
            if snapshot["connection"] == "online"
973f8fbdaf80atagen2026-09-28 09:10 UTC467
                && snapshot["timelines"]["!malice:localhost"]
973f8fbdaf80atagen2026-09-28 09:10 UTC468
                    .as_array()
973f8fbdaf80atagen2026-09-28 09:10 UTC469
                    .is_some_and(|items| {
973f8fbdaf80atagen2026-09-28 09:10 UTC470
                        items.iter().any(|item| {
973f8fbdaf80atagen2026-09-28 09:10 UTC471
                            item["Event"]["content"]["Message"]["body"] == "older channel message"
973f8fbdaf80atagen2026-09-28 09:10 UTC472
                                && item["Event"]["event_id"] == "$older:localhost"
973f8fbdaf80atagen2026-09-28 09:10 UTC473
                        }) && items.iter().any(|item| {
973f8fbdaf80atagen2026-09-28 09:10 UTC474
                            item["Event"]["content"]["Message"]["body"] == "backend room smoke"
973f8fbdaf80atagen2026-09-28 09:10 UTC475
                                && item["Event"]["event_id"] == "$sent:localhost"
973f8fbdaf80atagen2026-09-28 09:10 UTC476
                                && item["Event"]["send_state"] == "Sent"
973f8fbdaf80atagen2026-09-28 09:10 UTC477
                        })
973f8fbdaf80atagen2026-09-28 09:10 UTC478
                    })
973f8fbdaf80atagen2026-09-28 09:10 UTC479
            {
973f8fbdaf80atagen2026-09-28 09:10 UTC480
                break;
973f8fbdaf80atagen2026-09-28 09:10 UTC481
            }
973f8fbdaf80atagen2026-09-28 09:10 UTC482
            tokio::time::sleep(Duration::from_millis(50)).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC483
        }
973f8fbdaf80atagen2026-09-28 09:10 UTC484
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC485
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC486
    .expect("server-confirmed message appeared in online timeline SSE snapshot");
973f8fbdaf80atagen2026-09-28 09:10 UTC487
973f8fbdaf80atagen2026-09-28 09:10 UTC488
    drop(server);
973f8fbdaf80atagen2026-09-28 09:10 UTC489
    let _restored = start(port, &data_dir, &client).await;
973f8fbdaf80atagen2026-09-28 09:10 UTC490
    let session: Value = client
973f8fbdaf80atagen2026-09-28 09:10 UTC491
        .get(format!("{base}/api/session"))
973f8fbdaf80atagen2026-09-28 09:10 UTC492
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC493
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC494
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC495
        .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC496
        .json()
973f8fbdaf80atagen2026-09-28 09:10 UTC497
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC498
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC499
    assert_eq!(session["user_id"], logged_in["user_id"]);
973f8fbdaf80atagen2026-09-28 09:10 UTC500
    let mut events = client
973f8fbdaf80atagen2026-09-28 09:10 UTC501
        .get(format!("{base}/api/events"))
973f8fbdaf80atagen2026-09-28 09:10 UTC502
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC503
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC504
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC505
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC506
    assert_eq!(events.status(), StatusCode::OK);
973f8fbdaf80atagen2026-09-28 09:10 UTC507
    let initial = events.chunk().await.unwrap().expect("initial SSE frame");
973f8fbdaf80atagen2026-09-28 09:10 UTC508
    assert!(String::from_utf8_lossy(&initial).contains("\"type\":\"snapshot\""));
973f8fbdaf80atagen2026-09-28 09:10 UTC509
973f8fbdaf80atagen2026-09-28 09:10 UTC510
    matrix
973f8fbdaf80atagen2026-09-28 09:10 UTC511
        .mock_logout()
973f8fbdaf80atagen2026-09-28 09:10 UTC512
        .expect_access_token("abc123")
973f8fbdaf80atagen2026-09-28 09:10 UTC513
        .ok()
973f8fbdaf80atagen2026-09-28 09:10 UTC514
        .expect(1)
973f8fbdaf80atagen2026-09-28 09:10 UTC515
        .mount()
973f8fbdaf80atagen2026-09-28 09:10 UTC516
        .await;
973f8fbdaf80atagen2026-09-28 09:10 UTC517
    let logout = client
973f8fbdaf80atagen2026-09-28 09:10 UTC518
        .post(format!("{base}/api/logout"))
973f8fbdaf80atagen2026-09-28 09:10 UTC519
        .header(header::ORIGIN, &base)
973f8fbdaf80atagen2026-09-28 09:10 UTC520
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC521
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC522
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC523
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC524
    assert_eq!(logout.status(), StatusCode::NO_CONTENT);
973f8fbdaf80atagen2026-09-28 09:10 UTC525
    tokio::time::timeout(Duration::from_secs(2), async {
973f8fbdaf80atagen2026-09-28 09:10 UTC526
        while events.chunk().await.unwrap().is_some() {}
973f8fbdaf80atagen2026-09-28 09:10 UTC527
    })
973f8fbdaf80atagen2026-09-28 09:10 UTC528
    .await
973f8fbdaf80atagen2026-09-28 09:10 UTC529
    .expect("logout must close the authenticated event stream");
973f8fbdaf80atagen2026-09-28 09:10 UTC530
    let after_logout: Value = client
973f8fbdaf80atagen2026-09-28 09:10 UTC531
        .get(format!("{base}/api/session"))
973f8fbdaf80atagen2026-09-28 09:10 UTC532
        .header(header::COOKIE, &cookie)
973f8fbdaf80atagen2026-09-28 09:10 UTC533
        .send()
973f8fbdaf80atagen2026-09-28 09:10 UTC534
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC535
        .unwrap()
973f8fbdaf80atagen2026-09-28 09:10 UTC536
        .json()
973f8fbdaf80atagen2026-09-28 09:10 UTC537
        .await
973f8fbdaf80atagen2026-09-28 09:10 UTC538
        .unwrap();
973f8fbdaf80atagen2026-09-28 09:10 UTC539
    assert_eq!(after_logout["user_id"], Value::Null);
973f8fbdaf80atagen2026-09-28 09:10 UTC540
}