atagen

macltok

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