atagen

malice

public
Star0
Watch0
bc6dc294600ded1de15a26c1005907e772ff72cd
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
crates/malice-server/tests/integration.rsRust540 lines17.6 KB
1
use std::{
2
    net::TcpListener,
3
    path::PathBuf,
4
    process::{Child, Command, Stdio},
5
    sync::atomic::{AtomicBool, Ordering},
6
    time::Duration,
7
};
8
9
use matrix_sdk::test_utils::mocks::MatrixMockServer;
10
use reqwest::{Client, StatusCode, header};
11
use serde_json::{Value, json};
12
use tempfile::TempDir;
13
use wiremock::{
14
    Mock, ResponseTemplate,
15
    matchers::{method, path_regex},
16
};
17
18
struct Server(Child);
19
20
impl Drop for Server {
21
    fn drop(&mut self) {
22
        let _ = self.0.kill();
23
        let _ = self.0.wait();
24
    }
25
}
26
27
fn server_binary() -> PathBuf {
28
    option_env!("CARGO_BIN_EXE_malice")
29
        .map(PathBuf::from)
30
        .unwrap_or_else(|| {
31
            std::env::current_exe()
32
                .expect("integration test executable")
33
                .parent()
34
                .expect("cargo deps directory")
35
                .parent()
36
                .expect("cargo target directory")
37
                .join("malice")
38
        })
39
}
40
41
async fn start(port: u16, data_dir: &TempDir, client: &Client) -> Server {
42
    let child = Command::new(server_binary())
43
        .env("MALICE_BIND", format!("127.0.0.1:{port}"))
44
        .env("MALICE_DATA_DIR", data_dir.path())
45
        .env("MALICE_SESSION_COOKIE_SECURE", "false")
46
        .env("MALICE_ALLOW_HTTP_HOMESERVER", "true")
47
        .stdout(Stdio::null())
48
        .stderr(Stdio::inherit())
49
        .spawn()
50
        .expect("start Malice backend");
51
    let mut server = Server(child);
52
    tokio::time::timeout(Duration::from_secs(30), async {
53
        loop {
54
            if let Some(status) = server.0.try_wait().expect("poll backend") {
55
                panic!("Malice exited before health check: {status}");
56
            }
57
            if client
58
                .get(format!("http://127.0.0.1:{port}/api/health"))
59
                .send()
60
                .await
61
                .is_ok_and(|response| response.status().is_success())
62
            {
63
                break;
64
            }
65
            tokio::time::sleep(Duration::from_millis(100)).await;
66
        }
67
    })
68
    .await
69
    .expect("Malice did not start");
70
    server
71
}
72
73
fn unused_port() -> u16 {
74
    TcpListener::bind("127.0.0.1:0")
75
        .expect("bind ephemeral port")
76
        .local_addr()
77
        .expect("read ephemeral port")
78
        .port()
79
}
80
81
#[tokio::test]
82
async fn login_isolated_cookie_survives_restart_and_logout_revokes_it() {
83
    let matrix = MatrixMockServer::new().await;
84
    matrix.mock_versions().ok().mount().await;
85
    matrix.mock_login().ok().expect(1).mount().await;
86
    let first_room = AtomicBool::new(false);
87
    Mock::given(method("POST"))
88
        .and(path_regex("/sync$"))
89
        .respond_with(move |request: &wiremock::Request| {
90
            let body: Value = serde_json::from_slice(&request.body).unwrap();
91
            let rooms =
92
                if body["conn_id"] == "room-list" && !first_room.swap(true, Ordering::Relaxed) {
93
                    json!({"!malice:localhost": {
94
                        "initial": true,
95
                        "name": "Malice test room",
96
                        "joined_count": 1,
97
                        "required_state": [{
98
                            "type": "m.room.member",
99
                            "state_key": "@cheeky_monkey:matrix.org",
100
                            "sender": "@cheeky_monkey:matrix.org",
101
                            "event_id": "$join:localhost",
102
                            "origin_server_ts": 1,
103
                            "content": {"membership": "join"}
104
                        }],
105
                        "timeline": [{
106
                            "type": "m.room.message",
107
                            "sender": "@cheeky_monkey:matrix.org",
108
                            "event_id": "$recent:localhost",
109
                            "origin_server_ts": 2,
110
                            "content": {"msgtype":"m.text", "body":"recent channel message"}
111
                        },{
112
                            "type": "m.room.message",
113
                            "sender": "@cheeky_monkey:matrix.org",
114
                            "event_id": "$image:localhost",
115
                            "origin_server_ts": 3,
116
                            "content": {
117
                                "msgtype": "m.image",
118
                                "body": "image.png",
119
                                "url": "mxc://localhost/avatar",
120
                                "info": {"mimetype": "image/png", "size": 13}
121
                            }
122
                        }],
123
                        "prev_batch": "older-page"
124
                    }})
125
                } else {
126
                    json!({})
127
                };
128
            ResponseTemplate::new(200)
129
                .set_body_json(json!({"pos":"1","lists":{},"rooms":rooms,"extensions":{}}))
130
                .set_delay(Duration::from_millis(100))
131
        })
132
        .mount(matrix.server())
133
        .await;
134
    Mock::given(method("GET"))
135
        .and(path_regex("/rooms/.*/messages$"))
136
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
137
            "start": "older-page",
138
            "chunk": [{
139
                "type": "m.room.message",
140
                "room_id": "!malice:localhost",
141
                "sender": "@cheeky_monkey:matrix.org",
142
                "event_id": "$older:localhost",
143
                "origin_server_ts": 1,
144
                "content": {"msgtype":"m.text", "body":"older channel message"}
145
            }]
146
        })))
147
        .mount(matrix.server())
148
        .await;
149
    matrix
150
        .mock_room_state_encryption()
151
        .expect_access_token("abc123")
152
        .plain()
153
        .mount()
154
        .await;
155
    matrix
156
        .mock_room_send()
157
        .expect_access_token("abc123")
158
        .body_matches_partial_json(json!({"body":"backend room smoke"}))
159
        .ok(matrix_sdk::ruma::event_id!("$sent:localhost"))
160
        .expect(1)
161
        .mount()
162
        .await;
163
164
    Mock::given(method("GET"))
165
        .and(path_regex("/download/[^/]+/avatar$"))
166
        .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\nimage".to_vec()))
167
        .mount(matrix.server())
168
        .await;
169
    Mock::given(method("GET"))
170
        .and(path_regex("/download/[^/]+/svg$"))
171
        .respond_with(
172
            ResponseTemplate::new(200).set_body_bytes(b"<svg onload='alert(1)'/>".to_vec()),
173
        )
174
        .mount(matrix.server())
175
        .await;
176
177
    let data_dir = tempfile::tempdir().expect("temporary user stores");
178
    let client = Client::new();
179
    let port = unused_port();
180
    let base = format!("http://127.0.0.1:{port}");
181
    let server = start(port, &data_dir, &client).await;
182
183
    let guest: Value = client
184
        .get(format!("{base}/api/session"))
185
        .send()
186
        .await
187
        .unwrap()
188
        .json()
189
        .await
190
        .unwrap();
191
    assert_eq!(guest["user_id"], Value::Null);
192
    assert_eq!(
193
        client
194
            .get(format!("{base}/api/rooms"))
195
            .send()
196
            .await
197
            .unwrap()
198
            .status(),
199
        StatusCode::UNAUTHORIZED
200
    );
201
202
    let cross_origin = client
203
        .post(format!("{base}/api/login"))
204
        .header(header::ORIGIN, "https://attacker.example")
205
        .json(&json!({
206
            "homeserver": matrix.uri(),
207
            "username": "alice",
208
            "password": "test-password"
209
        }))
210
        .send()
211
        .await
212
        .unwrap();
213
    assert_eq!(cross_origin.status(), StatusCode::FORBIDDEN);
214
215
    let cross_port = client
216
        .post(format!("{base}/api/login"))
217
        .header(header::ORIGIN, format!("http://127.0.0.1:{}", port + 1))
218
        .json(&json!({
219
            "homeserver": matrix.uri(),
220
            "username": "alice",
221
            "password": "test-password"
222
        }))
223
        .send()
224
        .await
225
        .unwrap();
226
    assert_eq!(cross_port.status(), StatusCode::FORBIDDEN);
227
228
    let login = client
229
        .post(format!("{base}/api/login"))
230
        .header(header::ORIGIN, &base)
231
        .json(&json!({
232
            "homeserver": matrix.uri(),
233
            "username": "alice",
234
            "password": "test-password"
235
        }))
236
        .send()
237
        .await
238
        .unwrap();
239
    assert!(
240
        login.status().is_success(),
241
        "login returned {}",
242
        login.status()
243
    );
244
    let cookie = login
245
        .headers()
246
        .get(header::SET_COOKIE)
247
        .expect("opaque session cookie")
248
        .to_str()
249
        .unwrap()
250
        .split(';')
251
        .next()
252
        .unwrap()
253
        .to_owned();
254
    let logged_in: Value = login.json().await.unwrap();
255
    assert!(logged_in["user_id"].as_str().is_some());
256
    #[cfg(unix)]
257
    {
258
        use std::os::unix::fs::PermissionsExt;
259
        let sid = cookie.split_once('=').unwrap().1;
260
        let mode = |path: std::path::PathBuf| {
261
            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
262
        };
263
        assert_eq!(mode(data_dir.path().join("master.key")), 0o600);
264
        assert_eq!(mode(data_dir.path().join("sessions")), 0o700);
265
        assert_eq!(
266
            mode(data_dir.path().join("sessions").join(format!("{sid}.bin"))),
267
            0o600
268
        );
269
    }
270
    assert_eq!(
271
        client
272
            .get(format!("{base}/api/rooms"))
273
            .send()
274
            .await
275
            .unwrap()
276
            .status(),
277
        StatusCode::UNAUTHORIZED,
278
        "one browser must not inherit another browser's Matrix session"
279
    );
280
    assert_eq!(
281
        client
282
            .get(format!("{base}/api/media"))
283
            .query(&[("mxc", "mxc://localhost/avatar")])
284
            .send()
285
            .await
286
            .unwrap()
287
            .status(),
288
        StatusCode::UNAUTHORIZED,
289
    );
290
    assert_eq!(
291
        client
292
            .get(format!("{base}/api/media"))
293
            .header(header::COOKIE, &cookie)
294
            .query(&[("mxc", "not-a-mxc")])
295
            .send()
296
            .await
297
            .unwrap()
298
            .status(),
299
        StatusCode::BAD_REQUEST,
300
    );
301
    let avatar = client
302
        .get(format!("{base}/api/media"))
303
        .header(header::COOKIE, &cookie)
304
        .query(&[("mxc", "mxc://localhost/avatar")])
305
        .send()
306
        .await
307
        .unwrap();
308
    assert_eq!(avatar.status(), StatusCode::OK);
309
    assert_eq!(
310
        avatar.headers().get(header::CONTENT_TYPE).unwrap(),
311
        "image/png"
312
    );
313
    assert_eq!(
314
        avatar.bytes().await.unwrap().as_ref(),
315
        b"\x89PNG\r\n\x1a\nimage"
316
    );
317
    assert_eq!(
318
        client
319
            .get(format!("{base}/api/media"))
320
            .header(header::COOKIE, &cookie)
321
            .query(&[("mxc", "mxc://localhost/svg")])
322
            .send()
323
            .await
324
            .unwrap()
325
            .status(),
326
        StatusCode::UNSUPPORTED_MEDIA_TYPE,
327
    );
328
    let rooms = client
329
        .get(format!("{base}/api/rooms"))
330
        .header(header::COOKIE, &cookie)
331
        .send()
332
        .await
333
        .unwrap();
334
    assert!(rooms.status().is_success());
335
    let rooms: Value = rooms.json().await.unwrap();
336
    assert!(rooms["rooms"].is_array());
337
    tokio::time::timeout(Duration::from_secs(5), async {
338
        loop {
339
            let response: Value = client
340
                .get(format!("{base}/api/rooms"))
341
                .header(header::COOKIE, &cookie)
342
                .send()
343
                .await
344
                .unwrap()
345
                .json()
346
                .await
347
                .unwrap();
348
            if response["rooms"]
349
                .as_array()
350
                .unwrap()
351
                .iter()
352
                .any(|room| room["room_id"] == "!malice:localhost")
353
            {
354
                break;
355
            }
356
            tokio::time::sleep(Duration::from_millis(50)).await;
357
        }
358
    })
359
    .await
360
    .expect("joined room appeared in live room list");
361
    let open = client
362
        .post(format!("{base}/api/rooms/open"))
363
        .header(header::ORIGIN, &base)
364
        .header(header::COOKIE, &cookie)
365
        .json(&json!({"room_id":"!malice:localhost"}))
366
        .send()
367
        .await
368
        .unwrap();
369
    assert_eq!(open.status(), StatusCode::NO_CONTENT);
370
    let media_id = tokio::time::timeout(Duration::from_secs(10), async {
371
        loop {
372
            let mut stream = client
373
                .get(format!("{base}/api/events"))
374
                .header(header::COOKIE, &cookie)
375
                .send()
376
                .await
377
                .unwrap();
378
            let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
379
            let text = std::str::from_utf8(&frame).unwrap();
380
            let payload = text
381
                .lines()
382
                .find_map(|line| line.strip_prefix("data: "))
383
                .unwrap();
384
            let snapshot: Value = serde_json::from_str(payload).unwrap();
385
            if let Some(id) = snapshot["timelines"]["!malice:localhost"]
386
                .as_array()
387
                .and_then(|items| {
388
                    items.iter().find_map(|item| {
389
                        item["Event"]["content"]["Message"]["media"]["id"].as_str()
390
                    })
391
                })
392
            {
393
                break id.to_owned();
394
            }
395
            tokio::time::sleep(Duration::from_millis(50)).await;
396
        }
397
    })
398
    .await
399
    .expect("opened room registered image attachment");
400
    assert_eq!(
401
        client
402
            .get(format!("{base}/api/message-media"))
403
            .query(&[("media_id", media_id.as_str()), ("size", "Full")])
404
            .send()
405
            .await
406
            .unwrap()
407
            .status(),
408
        StatusCode::UNAUTHORIZED,
409
    );
410
    let attachment = client
411
        .get(format!("{base}/api/message-media"))
412
        .header(header::COOKIE, &cookie)
413
        .query(&[("media_id", media_id.as_str()), ("size", "Full")])
414
        .send()
415
        .await
416
        .unwrap();
417
    assert_eq!(attachment.status(), StatusCode::OK);
418
    assert_eq!(
419
        attachment.headers().get(header::CONTENT_TYPE).unwrap(),
420
        "image/png"
421
    );
422
    assert_eq!(
423
        attachment.bytes().await.unwrap().as_ref(),
424
        b"\x89PNG\r\n\x1a\nimage"
425
    );
426
    let sent = client
427
        .post(format!("{base}/api/rooms/send"))
428
        .header(header::ORIGIN, &base)
429
        .header(header::COOKIE, &cookie)
430
        .json(&json!({"room_id":"!malice:localhost", "body":"backend room smoke"}))
431
        .send()
432
        .await
433
        .unwrap();
434
    assert_eq!(sent.status(), StatusCode::NO_CONTENT);
435
    tokio::time::timeout(Duration::from_secs(5), async {
436
        loop {
437
            if matrix
438
                .received_requests()
439
                .await
440
                .unwrap()
441
                .iter()
442
                .any(|request| request.url.path().contains("/send/m.room.message/"))
443
            {
444
                break;
445
            }
446
            tokio::time::sleep(Duration::from_millis(50)).await;
447
        }
448
    })
449
    .await
450
    .expect("message reached Matrix send endpoint");
451
    tokio::time::timeout(Duration::from_secs(15), async {
452
        loop {
453
            let mut stream = client
454
                .get(format!("{base}/api/events"))
455
                .header(header::COOKIE, &cookie)
456
                .send()
457
                .await
458
                .unwrap();
459
            let frame = stream.chunk().await.unwrap().expect("SSE snapshot");
460
            let text = std::str::from_utf8(&frame).unwrap();
461
            let payload = text
462
                .lines()
463
                .find_map(|line| line.strip_prefix("data: "))
464
                .unwrap();
465
            let snapshot: Value = serde_json::from_str(payload).unwrap();
466
            if snapshot["connection"] == "online"
467
                && snapshot["timelines"]["!malice:localhost"]
468
                    .as_array()
469
                    .is_some_and(|items| {
470
                        items.iter().any(|item| {
471
                            item["Event"]["content"]["Message"]["body"] == "older channel message"
472
                                && item["Event"]["event_id"] == "$older:localhost"
473
                        }) && items.iter().any(|item| {
474
                            item["Event"]["content"]["Message"]["body"] == "backend room smoke"
475
                                && item["Event"]["event_id"] == "$sent:localhost"
476
                                && item["Event"]["send_state"] == "Sent"
477
                        })
478
                    })
479
            {
480
                break;
481
            }
482
            tokio::time::sleep(Duration::from_millis(50)).await;
483
        }
484
    })
485
    .await
486
    .expect("server-confirmed message appeared in online timeline SSE snapshot");
487
488
    drop(server);
489
    let _restored = start(port, &data_dir, &client).await;
490
    let session: Value = client
491
        .get(format!("{base}/api/session"))
492
        .header(header::COOKIE, &cookie)
493
        .send()
494
        .await
495
        .unwrap()
496
        .json()
497
        .await
498
        .unwrap();
499
    assert_eq!(session["user_id"], logged_in["user_id"]);
500
    let mut events = client
501
        .get(format!("{base}/api/events"))
502
        .header(header::COOKIE, &cookie)
503
        .send()
504
        .await
505
        .unwrap();
506
    assert_eq!(events.status(), StatusCode::OK);
507
    let initial = events.chunk().await.unwrap().expect("initial SSE frame");
508
    assert!(String::from_utf8_lossy(&initial).contains("\"type\":\"snapshot\""));
509
510
    matrix
511
        .mock_logout()
512
        .expect_access_token("abc123")
513
        .ok()
514
        .expect(1)
515
        .mount()
516
        .await;
517
    let logout = client
518
        .post(format!("{base}/api/logout"))
519
        .header(header::ORIGIN, &base)
520
        .header(header::COOKIE, &cookie)
521
        .send()
522
        .await
523
        .unwrap();
524
    assert_eq!(logout.status(), StatusCode::NO_CONTENT);
525
    tokio::time::timeout(Duration::from_secs(2), async {
526
        while events.chunk().await.unwrap().is_some() {}
527
    })
528
    .await
529
    .expect("logout must close the authenticated event stream");
530
    let after_logout: Value = client
531
        .get(format!("{base}/api/session"))
532
        .header(header::COOKIE, &cookie)
533
        .send()
534
        .await
535
        .unwrap()
536
        .json()
537
        .await
538
        .unwrap();
539
    assert_eq!(after_logout["user_id"], Value::Null);
540
}