atagen

macltok

public
Star0
Watch0
8127bd8b03da656d01d946c7a8e80da81653ed3c
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
tests/account.rsRust503 lines16.6 KB
1
#![cfg(feature = "sqlite")]
2
mod support;
3
4
use std::{path::PathBuf, sync::Arc, time::SystemTime};
5
6
use macltok::{
7
    AccountError, Accounts, EncryptedSessionStore, EngineConfig, Event, OAuthApplicationType,
8
    OAuthLogin, PasswordLogin, Restore, Session, SessionRecord, SessionStore, StoreConfig,
9
};
10
use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};
11
12
#[tokio::test]
13
async fn password_session_round_trips_and_discards_with_its_store() {
14
    let server = MatrixMockServer::new().await;
15
    server
16
        .mock_versions()
17
        .with_simplified_sliding_sync()
18
        .ok()
19
        .mount()
20
        .await;
21
    server.mock_login().ok().mount().await;
22
    server
23
        .mock_sliding_sync()
24
        .ok(v5::Response::new("1".to_owned()))
25
        .mount()
26
        .await;
27
    let root = temporary_path();
28
    let store_path = root.join("store");
29
    let record_path = root.join("records").join("session");
30
    let sessions = || {
31
        EncryptedSessionStore::file(
32
            &record_path,
33
            StoreConfig::sqlite(&store_path, "test store passphrase"),
34
        )
35
    };
36
    let config = EngineConfig::default().with_initial_history_limit(0);
37
    let session = Session::login_password(
38
        PasswordLogin::new(server.uri(), "example", "wordpass")
39
            .allow_insecure_loopback(true)
40
            .with_device_display_name("macltok test"),
41
        Arc::new(sessions()),
42
    )
43
    .await
44
    .unwrap();
45
    let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();
46
    let running = tokio::spawn(engine.run());
47
    support::receive_until(&mut events, |event| {
48
        matches!(event, Event::Authenticated { .. })
49
    })
50
    .await;
51
    support::shut_down(&handle, &mut events, running).await;
52
    drop(handle);
53
54
    let wrong = EncryptedSessionStore::file(
55
        &record_path,
56
        StoreConfig::sqlite(&store_path, "someone else's passphrase"),
57
    );
58
    assert!(wrong.load().await.is_err());
59
60
    let sessions = Arc::new(sessions());
61
    let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {
62
        panic!("the saved login didn't resume");
63
    };
64
    let (engine, handle, mut events) = session.build(config).await.unwrap();
65
    let running = tokio::spawn(engine.run());
66
    support::receive_until(&mut events, |event| {
67
        matches!(event, Event::Authenticated { .. })
68
    })
69
    .await;
70
    support::shut_down(&handle, &mut events, running).await;
71
    drop(handle);
72
73
    sessions.discard().await.unwrap();
74
    assert!(!record_path.exists());
75
    assert!(!store_path.exists());
76
    assert!(Session::restore(sessions).await.unwrap().is_none());
77
    std::fs::remove_dir_all(root).unwrap();
78
}
79
80
#[tokio::test]
81
async fn a_restart_after_a_soft_logout_still_knows_the_device() {
82
    let server = MatrixMockServer::new().await;
83
    server.mock_versions().ok().mount().await;
84
    server.mock_login().ok().mount().await;
85
    let root = temporary_path();
86
    let sessions = || {
87
        EncryptedSessionStore::file(
88
            root.join("session"),
89
            StoreConfig::sqlite(root.join("store"), "test store passphrase"),
90
        )
91
    };
92
    Session::login_password(
93
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
94
        Arc::new(sessions()),
95
    )
96
    .await
97
    .unwrap();
98
    sessions().soft_log_out().await.unwrap();
99
100
    let Some(Restore::SoftLoggedOut(soft)) = Session::restore(Arc::new(sessions())).await.unwrap()
101
    else {
102
        panic!("a soft logged out record came back as something else");
103
    };
104
    // the device matrix-sdk-test's canned login response hands out
105
    assert_eq!(soft.device_id, "GHTYAJCE");
106
    Session::login_password(soft.password_login("wordpass"), Arc::new(sessions()))
107
        .await
108
        .unwrap();
109
    let requests = server.server().received_requests().await.unwrap();
110
    let relogin: serde_json::Value = requests
111
        .iter()
112
        .rfind(|request| request.url.path().ends_with("/login"))
113
        .unwrap()
114
        .body_json()
115
        .unwrap();
116
    assert_eq!(relogin["device_id"], "GHTYAJCE");
117
    assert!(matches!(
118
        Session::restore(Arc::new(sessions())).await.unwrap(),
119
        Some(Restore::Resumed(_))
120
    ));
121
    std::fs::remove_dir_all(root).unwrap();
122
}
123
124
#[tokio::test]
125
async fn a_login_whose_first_save_fails_can_still_be_saved() {
126
    let server = MatrixMockServer::new().await;
127
    server.mock_versions().ok().mount().await;
128
    server.mock_login().ok().mount().await;
129
    let root = temporary_path();
130
    let records = root.join("records");
131
    std::fs::create_dir_all(&records).unwrap();
132
    let sessions = || {
133
        EncryptedSessionStore::file(
134
            records.join("session"),
135
            StoreConfig::sqlite(root.join("store"), "test store passphrase"),
136
        )
137
    };
138
    let writable = std::fs::metadata(&records).unwrap().permissions();
139
    let mut read_only = writable.clone();
140
    read_only.set_readonly(true);
141
    std::fs::set_permissions(&records, read_only).unwrap();
142
143
    let login = Session::login_password(
144
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
145
        Arc::new(sessions()),
146
    )
147
    .await;
148
    let Err(AccountError::Unsaved { session, .. }) = login else {
149
        panic!("a login that couldn't save its record didn't hand the session back");
150
    };
151
    std::fs::set_permissions(&records, writable).unwrap();
152
    drop(session.save().await.unwrap());
153
    assert!(matches!(
154
        Session::restore(Arc::new(sessions())).await.unwrap(),
155
        Some(Restore::Resumed(_))
156
    ));
157
    std::fs::remove_dir_all(root).unwrap();
158
}
159
160
#[tokio::test]
161
async fn a_store_takes_one_session_at_a_time() {
162
    let server = MatrixMockServer::new().await;
163
    server.mock_versions().ok().mount().await;
164
    server.mock_login().ok().mount().await;
165
    let root = temporary_path();
166
    let sessions = || {
167
        EncryptedSessionStore::file(
168
            root.join("session"),
169
            StoreConfig::sqlite(root.join("store"), "test store passphrase"),
170
        )
171
    };
172
    let (engine, handle, events) = Session::login_password(
173
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
174
        Arc::new(sessions()),
175
    )
176
    .await
177
    .unwrap()
178
    .build(EngineConfig::default())
179
    .await
180
    .unwrap();
181
182
    assert!(matches!(
183
        Session::restore(Arc::new(sessions())).await,
184
        Err(AccountError::StoreInUse)
185
    ));
186
    drop((engine, events));
187
    assert!(matches!(
188
        Session::restore(Arc::new(sessions())).await,
189
        Err(AccountError::StoreInUse)
190
    ));
191
    drop(handle);
192
    let resumed = Session::restore(Arc::new(sessions())).await.unwrap();
193
    assert!(matches!(resumed, Some(Restore::Resumed(_))));
194
    drop(resumed);
195
    sessions().discard().await.unwrap();
196
    assert!(!root.join("store").exists());
197
    std::fs::remove_dir_all(root).unwrap();
198
}
199
200
#[tokio::test]
201
async fn a_torn_or_failed_save_leaves_the_old_record() {
202
    let root = temporary_path();
203
    let record_path = root.join("session");
204
    let sessions = || {
205
        EncryptedSessionStore::file(
206
            &record_path,
207
            StoreConfig::sqlite(root.join("store"), "test store passphrase"),
208
        )
209
    };
210
    let temporary = root.join("session.1.0.tmp");
211
    sessions()
212
        .save(&record("https://old.example"))
213
        .await
214
        .unwrap();
215
216
    std::fs::write(&temporary, b"half a rec").unwrap();
217
    let loaded = sessions().load().await.unwrap().unwrap();
218
    assert_eq!(loaded.homeserver(), "https://old.example");
219
220
    let writable = std::fs::metadata(&root).unwrap().permissions();
221
    let mut read_only = writable.clone();
222
    read_only.set_readonly(true);
223
    std::fs::set_permissions(&root, read_only).unwrap();
224
    assert!(
225
        sessions()
226
            .save(&record("https://new.example"))
227
            .await
228
            .is_err()
229
    );
230
    let loaded = sessions().load().await.unwrap().unwrap();
231
    assert_eq!(loaded.homeserver(), "https://old.example");
232
233
    std::fs::set_permissions(&root, writable).unwrap();
234
    std::fs::remove_dir_all(root).unwrap();
235
}
236
237
#[tokio::test]
238
async fn a_refresh_is_saved_before_anyone_hears_of_it() {
239
    use matrix_sdk_test::test_json;
240
    use wiremock::{
241
        Mock, ResponseTemplate,
242
        matchers::{header, method, path, path_regex},
243
    };
244
245
    let server = MatrixMockServer::new().await;
246
    server.mock_versions().ok().mount().await;
247
    server
248
        .mock_login()
249
        .respond_with(
250
            ResponseTemplate::new(200).set_body_json(&*test_json::LOGIN_WITH_REFRESH_TOKEN),
251
        )
252
        .mount()
253
        .await;
254
    Mock::given(method("POST"))
255
        .and(path_regex("sync"))
256
        .and(header("authorization", "Bearer abc123"))
257
        .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
258
            "errcode": "M_UNKNOWN_TOKEN",
259
            "error": "expired",
260
            "soft_logout": false,
261
        })))
262
        .with_priority(1)
263
        .mount(server.server())
264
        .await;
265
    Mock::given(method("POST"))
266
        .and(path("/_matrix/client/v3/refresh"))
267
        .respond_with(
268
            ResponseTemplate::new(200).set_body_json(&*test_json::REFRESH_TOKEN_WITH_REFRESH_TOKEN),
269
        )
270
        .mount(server.server())
271
        .await;
272
    server
273
        .mock_sliding_sync()
274
        .ok(v5::Response::new("1".to_owned()))
275
        .mount()
276
        .await;
277
    let root = temporary_path();
278
    let sessions = Arc::new(EncryptedSessionStore::file(
279
        root.join("session"),
280
        StoreConfig::sqlite(root.join("store"), "test store passphrase"),
281
    ));
282
    let session = Session::login_password(
283
        PasswordLogin::new(server.uri(), "example", "wordpass").allow_insecure_loopback(true),
284
        sessions.clone(),
285
    )
286
    .await
287
    .unwrap();
288
    let (engine, handle, mut events) = session
289
        .build(EngineConfig::default().with_initial_history_limit(0))
290
        .await
291
        .unwrap();
292
    let running = tokio::spawn(engine.run());
293
    support::receive_until(&mut events, |event| {
294
        matches!(event, Event::SessionTokensChanged)
295
    })
296
    .await;
297
    let saved = serde_json::to_value(sessions.load().await.unwrap().unwrap()).unwrap();
298
    assert_eq!(saved["authentication"]["session"]["refresh_token"], "wxyz");
299
    support::shut_down(&handle, &mut events, running).await;
300
    drop(handle);
301
    std::fs::remove_dir_all(root).unwrap();
302
}
303
304
#[tokio::test]
305
async fn logging_in_again_after_a_soft_logout_keeps_the_device_and_its_store() {
306
    let server = MatrixMockServer::new().await;
307
    server.mock_versions().ok().mount().await;
308
    server.mock_login().ok().mount().await;
309
    let root = temporary_path();
310
    let accounts = Accounts::sqlite(&root, "test store passphrase");
311
    let login = |username: &str| {
312
        PasswordLogin::new(server.uri(), username, "wordpass").allow_insecure_loopback(true)
313
    };
314
    let device_ids = || async {
315
        let requests = server.server().received_requests().await.unwrap();
316
        requests
317
            .iter()
318
            .filter(|request| request.url.path().ends_with("/login"))
319
            .map(|request| request.body_json::<serde_json::Value>().unwrap()["device_id"].clone())
320
            .collect::<Vec<_>>()
321
    };
322
323
    let (first, session) = accounts
324
        .login_password(login("cheeky_monkey"))
325
        .await
326
        .unwrap();
327
    drop(session);
328
    assert!(matches!(
329
        accounts.login_password(login("cheeky_monkey")).await,
330
        Err(AccountError::AlreadyLoggedIn(_))
331
    ));
332
333
    first.sessions().soft_log_out().await.unwrap();
334
    let (resumed, session) = accounts
335
        .login_password(login(" Cheeky_Monkey "))
336
        .await
337
        .unwrap();
338
    drop(session);
339
    assert_eq!(resumed.id(), first.id());
340
    assert_eq!(device_ids().await.last().unwrap(), "GHTYAJCE");
341
342
    resumed.sessions().soft_log_out().await.unwrap();
343
    let (other, session) = accounts
344
        .login_password(login("someone_else"))
345
        .await
346
        .unwrap();
347
    drop(session);
348
    assert_ne!(other.id(), first.id());
349
    assert!(device_ids().await.last().unwrap().is_null());
350
    assert_eq!(accounts.list().await.unwrap().len(), 2);
351
    std::fs::remove_dir_all(root).unwrap();
352
}
353
354
#[tokio::test]
355
async fn a_refused_login_leaves_no_account_and_says_why() {
356
    use wiremock::ResponseTemplate;
357
358
    let server = MatrixMockServer::new().await;
359
    server.mock_versions().ok().mount().await;
360
    server
361
        .mock_login()
362
        .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
363
            "errcode": "M_FORBIDDEN",
364
            "error": "Invalid username or password",
365
        })))
366
        .mount()
367
        .await;
368
    let root = temporary_path();
369
    let accounts = Accounts::sqlite(&root, "test store passphrase");
370
    let Err(refused) = accounts
371
        .login_password(
372
            PasswordLogin::new(server.uri(), "example", "wrong").allow_insecure_loopback(true),
373
        )
374
        .await
375
    else {
376
        panic!("a refused login went through");
377
    };
378
    let server_error = refused.server().unwrap();
379
    assert_eq!(server_error.errcode, "M_FORBIDDEN");
380
    assert_eq!(server_error.message, "Invalid username or password");
381
    assert!(accounts.list().await.unwrap().is_empty());
382
    assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
383
    std::fs::remove_dir_all(root).unwrap();
384
}
385
386
#[tokio::test]
387
async fn a_homeserver_that_cant_be_one_is_refused_before_anything_is_sent() {
388
    let root = temporary_path();
389
    let sessions: Arc<dyn SessionStore> = Arc::new(EncryptedSessionStore::file(
390
        root.join("session"),
391
        StoreConfig::sqlite(root.join("store"), "test store passphrase"),
392
    ));
393
    for (homeserver, loopback) in [
394
        ("", true),
395
        ("  ", true),
396
        ("example .org", true),
397
        ("ftp://example.org", true),
398
        ("https://user@example.org", true),
399
        ("https://", true),
400
        ("example.org/path", true),
401
        ("http://example.org", false),
402
        ("http://example.org", true),
403
        ("http://localhost:8008", false),
404
    ] {
405
        let login =
406
            PasswordLogin::new(homeserver, "example", "wordpass").allow_insecure_loopback(loopback);
407
        let refused = Session::login_password(login, sessions.clone()).await;
408
        assert!(
409
            matches!(refused, Err(AccountError::InvalidHomeserver(_))),
410
            "{homeserver:?} got through"
411
        );
412
    }
413
    assert!(!root.exists());
414
}
415
416
fn record(homeserver: &str) -> SessionRecord {
417
    serde_json::from_value(serde_json::json!({
418
        "homeserver": homeserver,
419
        "authentication": {
420
            "type": "matrix",
421
            "session": {
422
                "user_id": "@example:example.org",
423
                "device_id": "DEVICE",
424
                "access_token": "secret",
425
            },
426
        },
427
    }))
428
    .unwrap()
429
}
430
431
fn temporary_path() -> PathBuf {
432
    let nonce = SystemTime::now()
433
        .duration_since(SystemTime::UNIX_EPOCH)
434
        .unwrap()
435
        .as_nanos();
436
    PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("macltok-account-{nonce}"))
437
}
438
439
#[tokio::test]
440
async fn an_oauth_login_survives_the_page_going_away_for_its_redirect() {
441
    let server = MatrixMockServer::new().await;
442
    server
443
        .mock_versions()
444
        .with_simplified_sliding_sync()
445
        .ok()
446
        .mount()
447
        .await;
448
    server.mock_who_am_i().ok().mount().await;
449
    let oauth = server.oauth();
450
    oauth.mock_server_metadata().ok().mount().await;
451
    oauth.mock_registration().ok().mount().await;
452
    oauth.mock_token().ok().mount().await;
453
    let root = temporary_path();
454
    let accounts = Accounts::sqlite(&root, "test store passphrase");
455
    let redirect_uri = "http://127.0.0.1/callback";
456
457
    let (account, pending) = accounts
458
        .begin_oauth(
459
            OAuthLogin::new(
460
                server.uri(),
461
                redirect_uri,
462
                "http://127.0.0.1/",
463
                OAuthApplicationType::Native,
464
            )
465
            .allow_insecure_loopback(true),
466
        )
467
        .await
468
        .unwrap();
469
    let state = url::Url::parse(pending.authorization_url())
470
        .unwrap()
471
        .query_pairs()
472
        .find(|(key, _)| key == "state")
473
        .map(|(_, state)| state.into_owned())
474
        .unwrap();
475
    drop(pending);
476
477
    assert!(matches!(
478
        account.restore().await.unwrap(),
479
        Some(Restore::OAuthPending)
480
    ));
481
    assert_eq!(
482
        accounts.list().await.unwrap().len(),
483
        1,
484
        "the redirect's store was swept"
485
    );
486
    assert!(matches!(
487
        accounts
488
            .finish_oauth(&format!("{redirect_uri}?code=42&state=someone-else"))
489
            .await,
490
        Err(AccountError::NoPendingOAuth)
491
    ));
492
493
    let (finished, session) = accounts
494
        .finish_oauth(&format!("{redirect_uri}?code=42&state={state}"))
495
        .await
496
        .unwrap();
497
    assert_eq!(finished.id(), account.id());
498
    drop(session);
499
    assert!(matches!(
500
        account.restore().await.unwrap(),
501
        Some(Restore::Resumed(_))
502
    ));
503
}