atagen

macltok

public
Star0
Watch0
3dab4a4e73f48986ea8e34215bdeb7d5d3870773
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/mentions/state_tests.rsRust458 lines14.4 KB
1
use std::collections::BTreeSet;
2
3
use ruma::{event_id, user_id};
4
5
use super::state::{Data, SavedEvent, Window, id};
6
use crate::{
7
    MentionCaptureState, MentionEntry, MentionKey, MentionReason, Message, MessageKind, SendState,
8
    Sender, TimelineContent, TimelineEvent, TimelineItem, TimelineItemId,
9
};
10
11
fn key(room: &str, event: &str) -> MentionKey {
12
    MentionKey {
13
        room_id: format!("!{room}:mentions.example").parse().unwrap(),
14
        event_id: format!("${event}:mentions.example").parse().unwrap(),
15
    }
16
}
17
18
fn entry(key: MentionKey, timestamp_ms: u64, discovery_revision: u64, read: bool) -> MentionEntry {
19
    MentionEntry {
20
        key,
21
        timestamp_ms,
22
        discovered_at_ms: timestamp_ms,
23
        discovery_revision,
24
        revision: discovery_revision,
25
        event: TimelineItem::DayDivider { timestamp_ms },
26
        reasons: vec![MentionReason::Direct],
27
        read,
28
        capture: MentionCaptureState::Ready,
29
        room_name: None,
30
        notification_suppressed: false,
31
    }
32
}
33
34
fn message_event(event_id: &str, timestamp_ms: u64, body: &str) -> TimelineItem {
35
    let event_id: ruma::OwnedEventId = event_id.parse().unwrap();
36
    TimelineItem::Event(Box::new(TimelineEvent {
37
        item_id: TimelineItemId::new(format!("item-{event_id}")),
38
        event_id: Some(event_id),
39
        transaction_id: None,
40
        sender: Sender {
41
            user_id: user_id!("@bob:mentions.example").to_owned(),
42
            display_name: Some("Bob".to_owned()),
43
            avatar: None,
44
            name_ambiguous: false,
45
        },
46
        timestamp_ms,
47
        own: false,
48
        send_state: SendState::Sent,
49
        content: TimelineContent::Message(Message {
50
            kind: MessageKind::Text,
51
            body: body.to_owned(),
52
            geo_uri: None,
53
            formatted_body: None,
54
            mentions: None,
55
            edited: false,
56
            media: None,
57
            link_previews: Vec::new(),
58
        }),
59
        reply_to: None,
60
        thread_root: None,
61
        thread_summary: None,
62
        reactions: Vec::new(),
63
        read_by: Vec::new(),
64
        editable: false,
65
        highlight: false,
66
    }))
67
}
68
69
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
70
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
71
async fn pagination_keeps_its_discovery_boundary_and_stable_ties() {
72
    let account = user_id!("@alice:mentions.example").to_owned();
73
    let ordered = vec![
74
        key("room", "latest"),
75
        key("room", "tie-z"),
76
        key("room", "tie-b"),
77
        key("room", "tie-a"),
78
        key("room", "oldest"),
79
    ];
80
    let mut data = Data {
81
        revision: 1,
82
        ..Data::default()
83
    };
84
    for (mention, timestamp) in [
85
        (ordered[0].clone(), 300),
86
        (ordered[1].clone(), 200),
87
        (ordered[2].clone(), 200),
88
        (ordered[3].clone(), 200),
89
        (ordered[4].clone(), 100),
90
    ] {
91
        data.entries
92
            .insert(id(&mention), entry(mention, timestamp, 1, false));
93
    }
94
95
    let first = data.page(&account, None, 2, &BTreeSet::new()).unwrap();
96
    assert_eq!(
97
        first
98
            .entries
99
            .iter()
100
            .map(|entry| entry.key.clone())
101
            .collect::<Vec<_>>(),
102
        ordered[..2].to_vec()
103
    );
104
    let mut cursor = first.next_cursor.unwrap();
105
106
    data.revision = 2;
107
    let later = key("room", "discovered-later");
108
    data.entries.insert(id(&later), entry(later, 50, 2, false));
109
110
    let mut paged = first
111
        .entries
112
        .into_iter()
113
        .map(|entry| entry.key)
114
        .collect::<Vec<_>>();
115
    loop {
116
        let page = data
117
            .page(&account, Some(&cursor), 2, &BTreeSet::new())
118
            .unwrap();
119
        paged.extend(page.entries.into_iter().map(|entry| entry.key));
120
        match page.next_cursor {
121
            Some(next) => cursor = next,
122
            None => break,
123
        }
124
    }
125
    assert_eq!(paged, ordered);
126
}
127
128
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
129
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
130
async fn pagination_rejects_a_forged_account_and_a_future_revision() {
131
    let account = user_id!("@alice:mentions.example").to_owned();
132
    let mut data = Data {
133
        revision: 1,
134
        ..Data::default()
135
    };
136
    for event in ["one", "two"] {
137
        let mention = key("room", event);
138
        data.entries
139
            .insert(id(&mention), entry(mention, 10, 1, false));
140
    }
141
    let cursor = data
142
        .page(&account, None, 1, &BTreeSet::new())
143
        .unwrap()
144
        .next_cursor
145
        .unwrap();
146
147
    let mut forged_account: serde_json::Value = serde_json::from_str(&cursor).unwrap();
148
    forged_account["account"] = serde_json::Value::String("@mallory:mentions.example".to_owned());
149
    assert!(
150
        data.page(
151
            &account,
152
            Some(&forged_account.to_string()),
153
            1,
154
            &BTreeSet::new()
155
        )
156
        .is_err()
157
    );
158
159
    let mut future_revision: serde_json::Value = serde_json::from_str(&cursor).unwrap();
160
    future_revision["revision"] = serde_json::json!(data.revision + 1);
161
    assert!(
162
        data.page(
163
            &account,
164
            Some(&future_revision.to_string()),
165
            1,
166
            &BTreeSet::new()
167
        )
168
        .is_err()
169
    );
170
}
171
172
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
173
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
174
async fn retention_discards_read_context_first_and_keeps_shared_events_until_unused() {
175
    let read_key = key("room", "read-mention");
176
    let unread_key = key("room", "unread-mention");
177
    let shared_key = key("room", "shared-context");
178
    let read_id = id(&read_key);
179
    let unread_id = id(&unread_key);
180
    let shared_id = id(&shared_key);
181
    let mut data = Data {
182
        revision: 3,
183
        ..Data::default()
184
    };
185
    data.entries
186
        .insert(read_id.clone(), entry(read_key, 10, 1, true));
187
    data.entries
188
        .insert(unread_id.clone(), entry(unread_key, 20, 2, false));
189
    data.events.insert(
190
        shared_id.clone(),
191
        SavedEvent {
192
            item: message_event("$shared-context:mentions.example", 15, "shared"),
193
            media: Vec::new(),
194
            raw: None,
195
        },
196
    );
197
    data.windows.insert(
198
        read_id.clone(),
199
        Window {
200
            before: vec![shared_id.clone()],
201
            target: None,
202
            after: Vec::new(),
203
            start: None,
204
            end: None,
205
        },
206
    );
207
    data.windows.insert(
208
        unread_id.clone(),
209
        Window {
210
            before: vec![shared_id.clone()],
211
            target: None,
212
            after: Vec::new(),
213
            start: None,
214
            end: None,
215
        },
216
    );
217
218
    assert!(data.evict_one().is_some());
219
    assert!(!data.windows.contains_key(&read_id));
220
    assert!(data.windows.contains_key(&unread_id));
221
    assert_eq!(
222
        data.entries[&read_id].capture,
223
        MentionCaptureState::Unavailable
224
    );
225
    assert!(!data.entries[&unread_id].read);
226
    assert!(data.events.contains_key(&shared_id));
227
228
    assert!(data.evict_one().is_some());
229
    assert!(data.windows.is_empty());
230
    assert!(data.entries.contains_key(&read_id));
231
    assert!(data.entries.contains_key(&unread_id));
232
    assert!(!data.events.contains_key(&shared_id));
233
}
234
235
#[cfg_attr(not(target_family = "wasm"), tokio::test)]
236
#[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
237
async fn context_uses_the_redacted_saved_event_instead_of_the_entry_preview() {
238
    let mention = key("room", "redacted-target");
239
    let mention_id = id(&mention);
240
    let original = message_event(
241
        "$redacted-target:mentions.example",
242
        42,
243
        "private original body",
244
    );
245
    let mut redacted = original.clone();
246
    if let TimelineItem::Event(event) = &mut redacted {
247
        event.content = TimelineContent::Redacted;
248
        event.reply_to = None;
249
        event.reactions.clear();
250
    }
251
    let mut data = Data::default();
252
    data.entries.insert(
253
        mention_id.clone(),
254
        MentionEntry {
255
            event: original,
256
            ..entry(mention.clone(), 42, 1, false)
257
        },
258
    );
259
    data.events.insert(
260
        mention_id.clone(),
261
        SavedEvent {
262
            item: redacted,
263
            media: Vec::new(),
264
            raw: None,
265
        },
266
    );
267
    data.windows.insert(
268
        mention_id,
269
        Window {
270
            before: Vec::new(),
271
            target: Some(id(&mention)),
272
            after: Vec::new(),
273
            start: Some("start".to_owned()),
274
            end: Some("end".to_owned()),
275
        },
276
    );
277
278
    let (context, media) = data.context(&mention).unwrap();
279
    let saved = context.context.unwrap().event;
280
    let TimelineItem::Event(saved) = saved else {
281
        panic!("the saved target should remain a timeline event");
282
    };
283
    assert_eq!(saved.content, TimelineContent::Redacted);
284
    assert_eq!(
285
        saved.event_id.as_deref(),
286
        Some(event_id!("$redacted-target:mentions.example"))
287
    );
288
    assert!(media.is_empty());
289
}
290
291
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
292
mod persistence {
293
    use std::sync::atomic::{AtomicUsize, Ordering};
294
295
    use ruma::{events::room::MediaSource, user_id};
296
    use serde_json::json;
297
298
    use super::*;
299
    use crate::{
300
        MediaId, MediaKind, StoreConfig,
301
        mentions::store::{Account, MentionsStore, Write},
302
        normalise::{MediaRegistration, MediaSourceInfo},
303
    };
304
305
    static STORES: AtomicUsize = AtomicUsize::new(0);
306
    const MAX_BYTES: usize = 64 * 1024 * 1024;
307
308
    fn account() -> Account {
309
        Account {
310
            homeserver: "https://mentions.example/".to_owned(),
311
            user_id: user_id!("@alice:mentions.example").to_owned(),
312
        }
313
    }
314
315
    fn config() -> StoreConfig {
316
        let number = STORES.fetch_add(1, Ordering::Relaxed);
317
        #[cfg(feature = "sqlite")]
318
        {
319
            StoreConfig::sqlite(
320
                std::env::temp_dir().join(format!(
321
                    "macltok-mentions-state-{}-{number}",
322
                    std::process::id()
323
                )),
324
                "state test passphrase",
325
            )
326
        }
327
        #[cfg(feature = "indexeddb")]
328
        {
329
            StoreConfig::indexeddb(
330
                format!("macltok-mentions-state-{}-{number}", js_sys::Date::now()),
331
                "state test passphrase",
332
            )
333
        }
334
    }
335
336
    fn media_registration() -> MediaRegistration {
337
        MediaRegistration {
338
            id: MediaId::new("test-scope#image".to_owned()),
339
            kind: MediaKind::Image,
340
            full: MediaSourceInfo {
341
                source: MediaSource::Plain(
342
                    ruma::mxc_uri!("mxc://mentions.example/image").to_owned(),
343
                ),
344
                mimetype: Some("image/png".to_owned()),
345
                declared_size: Some(1234),
346
            },
347
            thumbnail: None,
348
        }
349
    }
350
351
    fn saved_data() -> Data {
352
        let mention = key("persisted-room", "persisted-mention");
353
        let mention_id = id(&mention);
354
        let context_item =
355
            message_event("$persisted-mention:mentions.example", 42, "saved message");
356
        let mut data = Data {
357
            revision: 1,
358
            ..Data::default()
359
        };
360
        data.entries.insert(
361
            mention_id.clone(),
362
            MentionEntry {
363
                event: context_item.clone(),
364
                ..entry(mention.clone(), 42, 1, true)
365
            },
366
        );
367
        data.events.insert(
368
            mention_id.clone(),
369
            SavedEvent {
370
                item: context_item,
371
                media: vec![media_registration()],
372
                raw: Some(
373
                    json!({"event_id": "$persisted-mention:mentions.example", "body": "saved raw"}),
374
                ),
375
            },
376
        );
377
        data.windows.insert(
378
            mention_id.clone(),
379
            Window {
380
                before: Vec::new(),
381
                target: Some(mention_id),
382
                after: Vec::new(),
383
                start: Some("start-token".to_owned()),
384
                end: Some("end-token".to_owned()),
385
            },
386
        );
387
        data.history_cursor = Some("history-position".to_owned());
388
        data
389
    }
390
391
    async fn open(config: &StoreConfig) -> MentionsStore {
392
        MentionsStore::open(config, account(), MAX_BYTES)
393
            .await
394
            .unwrap()
395
    }
396
397
    #[cfg_attr(not(target_family = "wasm"), tokio::test)]
398
    #[cfg_attr(target_family = "wasm", wasm_bindgen_test::wasm_bindgen_test)]
399
    async fn encrypted_store_roundtrips_inbox_context_and_read_state() {
400
        let config = config();
401
        let data = saved_data();
402
        let mut store = open(&config).await;
403
        assert_eq!(
404
            store
405
                .commit(
406
                    0,
407
                    vec![Write::Set(
408
                        "inbox".to_owned(),
409
                        serde_json::value::to_raw_value(&data).unwrap(),
410
                    )],
411
                )
412
                .await
413
                .unwrap(),
414
            1
415
        );
416
        drop(store);
417
418
        let mut restored = open(&config).await;
419
        let mut restored_data: Data = restored.read("inbox").unwrap().unwrap();
420
        assert_eq!(restored.revision(), restored_data.revision);
421
        assert!(restored_data.entries.values().next().unwrap().read);
422
        let mention = restored_data.entries.values().next().unwrap().key.clone();
423
        let (context, media) = restored_data.context(&mention).unwrap();
424
        assert_eq!(
425
            context.context.unwrap().start_token.as_deref(),
426
            Some("start-token")
427
        );
428
        assert_eq!(media.len(), 1);
429
        assert_eq!(media[0].id.as_str(), "test-scope#image");
430
        assert_eq!(media[0].full.mimetype.as_deref(), Some("image/png"));
431
        let event = restored_data.events.values().next().unwrap();
432
        assert_eq!(event.raw.as_ref().unwrap()["body"], "saved raw");
433
434
        restored_data.revision = 2;
435
        let updated_entry = restored_data.entries.values_mut().next().unwrap();
436
        updated_entry.read = false;
437
        updated_entry.revision = 2;
438
        restored
439
            .commit(
440
                1,
441
                vec![Write::Set(
442
                    "inbox".to_owned(),
443
                    serde_json::value::to_raw_value(&restored_data).unwrap(),
444
                )],
445
            )
446
            .await
447
            .unwrap();
448
        drop(restored);
449
450
        let restored = open(&config).await;
451
        let restored_data: Data = restored.read("inbox").unwrap().unwrap();
452
        assert_eq!(restored.revision(), 2);
453
        assert_eq!(restored_data.revision, 2);
454
        assert!(!restored_data.entries.values().next().unwrap().read);
455
        drop(restored);
456
        config.delete().await.unwrap();
457
    }
458
}