diff --git a/src/blob.rs b/src/blob.rs index 1574956bf..036bc4f3f 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -844,821 +844,4 @@ fn add_white_bg(img: &mut DynamicImage) { } #[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - use crate::message::{Message, Viewtype}; - use crate::sql; - use crate::test_utils::{self, TestContext}; - use crate::tools::SystemTime; - - fn check_image_size(path: impl AsRef, width: u32, height: u32) -> image::DynamicImage { - tokio::task::block_in_place(move || { - let img = ImageReader::open(path) - .expect("failed to open image") - .with_guessed_format() - .expect("failed to guess format") - .decode() - .expect("failed to decode image"); - assert_eq!(img.width(), width, "invalid width"); - assert_eq!(img.height(), height, "invalid height"); - img - }) - } - - const FILE_BYTES: &[u8] = b"hello"; - const FILE_DEDUPLICATED: &str = "ea8f163db38682925e4491c5e58d4bb.txt"; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create() { - let t = TestContext::new().await; - let blob = - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); - let fname = t.get_blobdir().join(FILE_DEDUPLICATED); - let data = fs::read(fname).await.unwrap(); - assert_eq!(data, FILE_BYTES); - assert_eq!(blob.as_name(), format!("$BLOBDIR/{FILE_DEDUPLICATED}")); - assert_eq!(blob.to_abs_path(), t.get_blobdir().join(FILE_DEDUPLICATED)); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_lowercase_ext() { - let t = TestContext::new().await; - let blob = - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.TXT").unwrap(); - assert!( - blob.as_name().ends_with(".txt"), - "Blob {blob:?} should end with .txt" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_as_file_name() { - let t = TestContext::new().await; - let blob = - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); - assert_eq!(blob.as_file_name(), FILE_DEDUPLICATED); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_as_rel_path() { - let t = TestContext::new().await; - let blob = - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); - assert_eq!(blob.as_rel_path(), Path::new(FILE_DEDUPLICATED)); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_suffix() { - let t = TestContext::new().await; - let blob = - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); - assert_eq!(blob.suffix(), Some("txt")); - let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "bar").unwrap(); - assert_eq!(blob.suffix(), None); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_dup() { - let t = TestContext::new().await; - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); - let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED); - assert!(foo_path.exists()); - BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.txt").unwrap(); - let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap(); - while let Ok(Some(dirent)) = dir.next_entry().await { - let fname = dirent.file_name(); - if fname == foo_path.file_name().unwrap() { - assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES); - } else { - let name = fname.to_str().unwrap(); - assert!(name.ends_with(".txt")); - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_double_ext() { - let t = TestContext::new().await; - BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.tar.gz").unwrap(); - let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED).with_extension("gz"); - assert!(foo_path.exists()); - BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.tar.gz").unwrap(); - let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap(); - while let Ok(Some(dirent)) = dir.next_entry().await { - let fname = dirent.file_name(); - if fname == foo_path.file_name().unwrap() { - assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES); - } else { - let name = fname.to_str().unwrap(); - println!("{name}"); - assert_eq!(name.starts_with("foo"), false); - assert_eq!(name.ends_with(".tar.gz"), false); - assert!(name.ends_with(".gz")); - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_long_names() { - let t = TestContext::new().await; - let s = format!("file.{}", "a".repeat(100)); - let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"data", &s).unwrap(); - let blobname = blob.as_name().split('/').last().unwrap(); - assert!(blobname.len() < 70); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_and_copy() { - let t = TestContext::new().await; - let src = t.dir.path().join("src"); - fs::write(&src, b"boo").await.unwrap(); - let blob = BlobObject::create_and_copy(&t, src.as_ref()).await.unwrap(); - assert_eq!(blob.as_name(), "$BLOBDIR/src"); - let data = fs::read(blob.to_abs_path()).await.unwrap(); - assert_eq!(data, b"boo"); - - let whoops = t.dir.path().join("whoops"); - assert!(BlobObject::create_and_copy(&t, whoops.as_ref()) - .await - .is_err()); - let whoops = t.get_blobdir().join("whoops"); - assert!(!whoops.exists()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_from_path() { - let t = TestContext::new().await; - - let src_ext = t.dir.path().join("external"); - fs::write(&src_ext, b"boo").await.unwrap(); - let blob = BlobObject::new_from_path(&t, src_ext.as_ref()) - .await - .unwrap(); - assert_eq!(blob.as_name(), "$BLOBDIR/external"); - let data = fs::read(blob.to_abs_path()).await.unwrap(); - assert_eq!(data, b"boo"); - - let src_int = t.get_blobdir().join("internal"); - fs::write(&src_int, b"boo").await.unwrap(); - let blob = BlobObject::new_from_path(&t, &src_int).await.unwrap(); - assert_eq!(blob.as_name(), "$BLOBDIR/internal"); - let data = fs::read(blob.to_abs_path()).await.unwrap(); - assert_eq!(data, b"boo"); - } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_from_name_long() { - let t = TestContext::new().await; - let src_ext = t.dir.path().join("autocrypt-setup-message-4137848473.html"); - fs::write(&src_ext, b"boo").await.unwrap(); - let blob = BlobObject::new_from_path(&t, src_ext.as_ref()) - .await - .unwrap(); - assert_eq!( - blob.as_name(), - "$BLOBDIR/autocrypt-setup-message-4137848473.html" - ); - } - - #[test] - fn test_is_blob_name() { - assert!(BlobObject::is_acceptible_blob_name("foo")); - assert!(BlobObject::is_acceptible_blob_name("foo.txt")); - assert!(BlobObject::is_acceptible_blob_name("f".repeat(128))); - assert!(!BlobObject::is_acceptible_blob_name("foo/bar")); - assert!(!BlobObject::is_acceptible_blob_name("foo\\bar")); - assert!(!BlobObject::is_acceptible_blob_name("foo\x00bar")); - } - - #[test] - fn test_sanitise_name() { - let (stem, ext) = - BlobObject::sanitise_name("Я ЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯ.txt"); - assert_eq!(ext, ".txt"); - assert!(!stem.is_empty()); - - // the extensions are kept together as between stem and extension a number may be added - - // and `foo.tar.gz` should become `foo-1234.tar.gz` and not `foo.tar-1234.gz` - let (stem, ext) = BlobObject::sanitise_name("wot.tar.gz"); - assert_eq!(stem, "wot"); - assert_eq!(ext, ".tar.gz"); - - let (stem, ext) = BlobObject::sanitise_name(".foo.bar"); - assert_eq!(stem, ""); - assert_eq!(ext, ".foo.bar"); - - let (stem, ext) = BlobObject::sanitise_name("foo?.bar"); - assert!(stem.contains("foo")); - assert!(!stem.contains('?')); - assert_eq!(ext, ".bar"); - - let (stem, ext) = BlobObject::sanitise_name("no-extension"); - assert_eq!(stem, "no-extension"); - assert_eq!(ext, ""); - - let (stem, ext) = BlobObject::sanitise_name("path/ignored\\this: is* forbidden?.c"); - assert_eq!(ext, ".c"); - assert!(!stem.contains("path")); - assert!(!stem.contains("ignored")); - assert!(stem.contains("this")); - assert!(stem.contains("forbidden")); - assert!(!stem.contains('/')); - assert!(!stem.contains('\\')); - assert!(!stem.contains(':')); - assert!(!stem.contains('*')); - assert!(!stem.contains('?')); - - let (stem, ext) = BlobObject::sanitise_name( - "file.with_lots_of_characters_behind_point_and_double_ending.tar.gz", - ); - assert_eq!( - stem, - "file.with_lots_of_characters_behind_point_and_double_ending" - ); - assert_eq!(ext, ".tar.gz"); - - let (stem, ext) = BlobObject::sanitise_name("a. tar.tar.gz"); - assert_eq!(stem, "a. tar"); - assert_eq!(ext, ".tar.gz"); - - let (stem, ext) = BlobObject::sanitise_name("Guia_uso_GNB (v0.8).pdf"); - assert_eq!(stem, "Guia_uso_GNB (v0.8)"); - assert_eq!(ext, ".pdf"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_add_white_bg() { - let t = TestContext::new().await; - let bytes0 = include_bytes!("../test-data/image/logo.png").as_slice(); - let bytes1 = include_bytes!("../test-data/image/avatar900x900.png").as_slice(); - for (bytes, color) in [ - (bytes0, [255u8, 255, 255, 255]), - (bytes1, [253u8, 198, 0, 255]), - ] { - let avatar_src = t.dir.path().join("avatar.png"); - fs::write(&avatar_src, bytes).await.unwrap(); - - let mut blob = BlobObject::new_from_path(&t, &avatar_src).await.unwrap(); - let img_wh = 128; - let maybe_sticker = &mut false; - let strict_limits = true; - blob.recode_to_size(&t, None, maybe_sticker, img_wh, 20_000, strict_limits) - .unwrap(); - tokio::task::block_in_place(move || { - let img = ImageReader::open(blob.to_abs_path()) - .unwrap() - .with_guessed_format() - .unwrap() - .decode() - .unwrap(); - assert!(img.width() == img_wh); - assert!(img.height() == img_wh); - assert_eq!(img.get_pixel(0, 0), Rgba(color)); - }); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_selfavatar_outside_blobdir() { - async fn file_size(path_buf: &Path) -> u64 { - fs::metadata(path_buf).await.unwrap().len() - } - - let t = TestContext::new().await; - let avatar_src = t.dir.path().join("avatar.jpg"); - let avatar_bytes = include_bytes!("../test-data/image/avatar1000x1000.jpg"); - fs::write(&avatar_src, avatar_bytes).await.unwrap(); - t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) - .await - .unwrap(); - let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); - let avatar_path = Path::new(&avatar_blob); - assert!( - avatar_blob.ends_with("d98cd30ed8f2129bf3968420208849d.jpg"), - "The avatar filename should be its hash, put instead it's {avatar_blob}" - ); - let scaled_avatar_size = file_size(avatar_path).await; - assert!(scaled_avatar_size < avatar_bytes.len() as u64); - - check_image_size(avatar_src, 1000, 1000); - check_image_size( - &avatar_blob, - constants::BALANCED_AVATAR_SIZE, - constants::BALANCED_AVATAR_SIZE, - ); - - let mut blob = BlobObject::new_from_path(&t, avatar_path).await.unwrap(); - let maybe_sticker = &mut false; - let strict_limits = true; - blob.recode_to_size(&t, None, maybe_sticker, 1000, 3000, strict_limits) - .unwrap(); - let new_file_size = file_size(&blob.to_abs_path()).await; - assert!(new_file_size <= 3000); - assert!(new_file_size > 2000); - // The new file should be smaller: - assert!(new_file_size < scaled_avatar_size); - // And the original file should not be touched: - assert_eq!(file_size(avatar_path).await, scaled_avatar_size); - tokio::task::block_in_place(move || { - let img = ImageReader::open(blob.to_abs_path()) - .unwrap() - .with_guessed_format() - .unwrap() - .decode() - .unwrap(); - assert!(img.width() > 130); - assert_eq!(img.width(), img.height()); - }); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_selfavatar_in_blobdir() { - let t = TestContext::new().await; - let avatar_src = t.get_blobdir().join("avatar.png"); - fs::write(&avatar_src, test_utils::AVATAR_900x900_BYTES) - .await - .unwrap(); - - check_image_size(&avatar_src, 900, 900); - - t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) - .await - .unwrap(); - let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); - assert!( - avatar_cfg.ends_with("9e7f409ac5c92b942cc4f31cee2770a.png"), - "Avatar file name {avatar_cfg} should end with its hash" - ); - - check_image_size( - avatar_cfg, - constants::BALANCED_AVATAR_SIZE, - constants::BALANCED_AVATAR_SIZE, - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_selfavatar_copy_without_recode() { - let t = TestContext::new().await; - let avatar_src = t.dir.path().join("avatar.png"); - let avatar_bytes = include_bytes!("../test-data/image/avatar64x64.png"); - fs::write(&avatar_src, avatar_bytes).await.unwrap(); - let avatar_blob = t.get_blobdir().join("e9b6c7a78aa2e4f415644f55a553e73.png"); - assert!(!avatar_blob.exists()); - t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) - .await - .unwrap(); - assert!(avatar_blob.exists()); - assert_eq!( - fs::metadata(&avatar_blob).await.unwrap().len(), - avatar_bytes.len() as u64 - ); - let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap(); - assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string())); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_recode_image_1() { - let bytes = include_bytes!("../test-data/image/avatar1000x1000.jpg"); - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "0", - bytes, - extension: "jpg", - has_exif: true, - original_width: 1000, - original_height: 1000, - compressed_width: 1000, - compressed_height: 1000, - ..Default::default() - } - .test() - .await - .unwrap(); - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "1", - bytes, - extension: "jpg", - has_exif: true, - original_width: 1000, - original_height: 1000, - compressed_width: 1000, - compressed_height: 1000, - ..Default::default() - } - .test() - .await - .unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_recode_image_2() { - // The "-rotated" files are rotated by 270 degrees using the Exif metadata - let bytes = include_bytes!("../test-data/image/rectangle2000x1800-rotated.jpg"); - let img_rotated = SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "0", - bytes, - extension: "jpg", - has_exif: true, - original_width: 2000, - original_height: 1800, - orientation: 270, - compressed_width: 1800, - compressed_height: 2000, - ..Default::default() - } - .test() - .await - .unwrap(); - assert_correct_rotation(&img_rotated); - - let mut buf = Cursor::new(vec![]); - img_rotated.write_to(&mut buf, ImageFormat::Jpeg).unwrap(); - let bytes = buf.into_inner(); - - let img_rotated = SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "1", - bytes: &bytes, - extension: "jpg", - original_width: 1800, - original_height: 2000, - compressed_width: 1800, - compressed_height: 2000, - ..Default::default() - } - .test() - .await - .unwrap(); - assert_correct_rotation(&img_rotated); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_recode_image_balanced_png() { - let bytes = include_bytes!("../test-data/image/screenshot.png"); - - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "0", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: 1920, - compressed_height: 1080, - ..Default::default() - } - .test() - .await - .unwrap(); - - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "1", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: constants::WORSE_IMAGE_SIZE, - compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920, - ..Default::default() - } - .test() - .await - .unwrap(); - - SendImageCheckMediaquality { - viewtype: Viewtype::File, - media_quality_config: "1", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: 1920, - compressed_height: 1080, - ..Default::default() - } - .test() - .await - .unwrap(); - - SendImageCheckMediaquality { - viewtype: Viewtype::File, - media_quality_config: "1", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: 1920, - compressed_height: 1080, - set_draft: true, - ..Default::default() - } - .test() - .await - .unwrap(); - - // This will be sent as Image, see [`BlobObject::maybe_sticker`] for explanation. - SendImageCheckMediaquality { - viewtype: Viewtype::Sticker, - media_quality_config: "0", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: 1920, - compressed_height: 1080, - ..Default::default() - } - .test() - .await - .unwrap(); - } - - /// Tests that RGBA PNG can be recoded into JPEG - /// by dropping alpha channel. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_recode_image_rgba_png_to_jpeg() { - let bytes = include_bytes!("../test-data/image/screenshot-rgba.png"); - - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "1", - bytes, - extension: "png", - original_width: 1920, - original_height: 1080, - compressed_width: constants::WORSE_IMAGE_SIZE, - compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920, - ..Default::default() - } - .test() - .await - .unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_recode_image_huge_jpg() { - let bytes = include_bytes!("../test-data/image/screenshot.jpg"); - SendImageCheckMediaquality { - viewtype: Viewtype::Image, - media_quality_config: "0", - bytes, - extension: "jpg", - has_exif: true, - original_width: 1920, - original_height: 1080, - compressed_width: constants::BALANCED_IMAGE_SIZE, - compressed_height: constants::BALANCED_IMAGE_SIZE * 1080 / 1920, - ..Default::default() - } - .test() - .await - .unwrap(); - } - - fn assert_correct_rotation(img: &DynamicImage) { - // The test images are black in the bottom left corner after correctly applying - // the EXIF orientation - - let [luma] = img.get_pixel(10, 10).to_luma().0; - assert_eq!(luma, 255); - let [luma] = img.get_pixel(img.width() - 10, 10).to_luma().0; - assert_eq!(luma, 255); - let [luma] = img - .get_pixel(img.width() - 10, img.height() - 10) - .to_luma() - .0; - assert_eq!(luma, 255); - let [luma] = img.get_pixel(10, img.height() - 10).to_luma().0; - assert_eq!(luma, 0); - } - - #[derive(Default)] - struct SendImageCheckMediaquality<'a> { - pub(crate) viewtype: Viewtype, - pub(crate) media_quality_config: &'a str, - pub(crate) bytes: &'a [u8], - pub(crate) extension: &'a str, - pub(crate) has_exif: bool, - pub(crate) original_width: u32, - pub(crate) original_height: u32, - pub(crate) orientation: i32, - pub(crate) compressed_width: u32, - pub(crate) compressed_height: u32, - pub(crate) set_draft: bool, - } - - impl SendImageCheckMediaquality<'_> { - pub(crate) async fn test(self) -> anyhow::Result { - let viewtype = self.viewtype; - let media_quality_config = self.media_quality_config; - let bytes = self.bytes; - let extension = self.extension; - let has_exif = self.has_exif; - let original_width = self.original_width; - let original_height = self.original_height; - let orientation = self.orientation; - let compressed_width = self.compressed_width; - let compressed_height = self.compressed_height; - let set_draft = self.set_draft; - - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - alice - .set_config(Config::MediaQuality, Some(media_quality_config)) - .await?; - let file = alice.get_blobdir().join("file").with_extension(extension); - let file_name = format!("file.{extension}"); - - fs::write(&file, &bytes) - .await - .context("failed to write file")?; - check_image_size(&file, original_width, original_height); - - let (_, exif) = image_metadata(&std::fs::File::open(&file)?)?; - if has_exif { - let exif = exif.unwrap(); - assert_eq!(exif_orientation(&exif, &alice), orientation); - } else { - assert!(exif.is_none()); - } - - let mut msg = Message::new(viewtype); - msg.set_file_and_deduplicate(&alice, &file, Some(&file_name), None)?; - let chat = alice.create_chat(&bob).await; - if set_draft { - chat.id.set_draft(&alice, Some(&mut msg)).await.unwrap(); - msg = chat.id.get_draft(&alice).await.unwrap().unwrap(); - assert_eq!(msg.get_viewtype(), Viewtype::File); - } - let sent = alice.send_msg(chat.id, &mut msg).await; - let alice_msg = alice.get_last_msg().await; - assert_eq!(alice_msg.get_width() as u32, compressed_width); - assert_eq!(alice_msg.get_height() as u32, compressed_height); - let file_saved = alice - .get_blobdir() - .join("saved-".to_string() + &alice_msg.get_filename().unwrap()); - alice_msg.save_file(&alice, &file_saved).await?; - check_image_size(file_saved, compressed_width, compressed_height); - - let bob_msg = bob.recv_msg(&sent).await; - assert_eq!(bob_msg.get_viewtype(), Viewtype::Image); - assert_eq!(bob_msg.get_width() as u32, compressed_width); - assert_eq!(bob_msg.get_height() as u32, compressed_height); - let file_saved = bob - .get_blobdir() - .join("saved-".to_string() + &bob_msg.get_filename().unwrap()); - bob_msg.save_file(&bob, &file_saved).await?; - if viewtype == Viewtype::File { - assert_eq!(file_saved.extension().unwrap(), extension); - let bytes1 = fs::read(&file_saved).await?; - assert_eq!(&bytes1, bytes); - } - - let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?; - assert!(exif.is_none()); - - let img = check_image_size(file_saved, compressed_width, compressed_height); - Ok(img) - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_send_big_gif_as_image() -> Result<()> { - let bytes = include_bytes!("../test-data/image/screenshot.gif"); - let (width, height) = (1920u32, 1080u32); - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - alice - .set_config( - Config::MediaQuality, - Some(&(MediaQuality::Worse as i32).to_string()), - ) - .await?; - let file = alice.get_blobdir().join("file").with_extension("gif"); - fs::write(&file, &bytes) - .await - .context("failed to write file")?; - let mut msg = Message::new(Viewtype::Image); - msg.set_file_and_deduplicate(&alice, &file, Some("file.gif"), None)?; - let chat = alice.create_chat(&bob).await; - let sent = alice.send_msg(chat.id, &mut msg).await; - let bob_msg = bob.recv_msg(&sent).await; - // DC must detect the image as GIF and send it w/o reencoding. - assert_eq!(bob_msg.get_viewtype(), Viewtype::Gif); - assert_eq!(bob_msg.get_width() as u32, width); - assert_eq!(bob_msg.get_height() as u32, height); - let file_saved = bob - .get_blobdir() - .join("saved-".to_string() + &bob_msg.get_filename().unwrap()); - bob_msg.save_file(&bob, &file_saved).await?; - let (file_size, _) = image_metadata(&std::fs::File::open(&file_saved)?)?; - assert_eq!(file_size, bytes.len() as u64); - check_image_size(file_saved, width, height); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_send_gif_as_sticker() -> Result<()> { - let bytes = include_bytes!("../test-data/image/image100x50.gif"); - let alice = &TestContext::new_alice().await; - let file = alice.get_blobdir().join("file").with_extension("gif"); - fs::write(&file, &bytes) - .await - .context("failed to write file")?; - let mut msg = Message::new(Viewtype::Sticker); - msg.set_file_and_deduplicate(alice, &file, None, None)?; - let chat = alice.get_self_chat().await; - let sent = alice.send_msg(chat.id, &mut msg).await; - let msg = Message::load_from_db(alice, sent.sender_msg_id).await?; - // Message::force_sticker() wasn't used, still Viewtype::Sticker is preserved because of the - // extension. - assert_eq!(msg.get_viewtype(), Viewtype::Sticker); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_and_deduplicate() -> Result<()> { - let t = TestContext::new().await; - - let path = t.get_blobdir().join("anyfile.dat"); - fs::write(&path, b"bla").await?; - let blob = BlobObject::create_and_deduplicate(&t, &path, &path)?; - assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f.dat"); - assert_eq!(path.exists(), false); - - assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla"); - - fs::write(&path, b"bla").await?; - let blob2 = BlobObject::create_and_deduplicate(&t, &path, &path)?; - assert_eq!(blob2.name, blob.name); - - let path_outside_blobdir = t.dir.path().join("anyfile.dat"); - fs::write(&path_outside_blobdir, b"bla").await?; - let blob3 = - BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?; - assert!(path_outside_blobdir.exists()); - assert_eq!(blob3.name, blob.name); - - fs::write(&path, b"blabla").await?; - let blob4 = BlobObject::create_and_deduplicate(&t, &path, &path)?; - assert_ne!(blob4.name, blob.name); - - fs::remove_dir_all(t.get_blobdir()).await?; - let blob5 = - BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?; - assert_eq!(blob5.name, blob.name); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_create_and_deduplicate_from_bytes() -> Result<()> { - let t = TestContext::new().await; - - fs::remove_dir(t.get_blobdir()).await?; - let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?; - assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f"); - - assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla"); - let modified1 = blob.to_abs_path().metadata()?.modified()?; - - // Test that the modification time of the file is updated when a new file is created - // so that it's not deleted during housekeeping. - // We can't use SystemTime::shift() here because file creation uses the actual OS time, - // which we can't mock from our code. - tokio::time::sleep(Duration::from_millis(1100)).await; - - let blob2 = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?; - assert_eq!(blob2.name, blob.name); - - let modified2 = blob.to_abs_path().metadata()?.modified()?; - assert_ne!(modified1, modified2); - sql::housekeeping(&t).await?; - assert!(blob2.to_abs_path().exists()); - - // If we do shift the time by more than 1h, the blob file will be deleted during housekeeping: - SystemTime::shift(Duration::from_secs(65 * 60)); - sql::housekeeping(&t).await?; - assert_eq!(blob2.to_abs_path().exists(), false); - - let blob3 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?; - assert_ne!(blob3.name, blob.name); - - { - // If something goes wrong and the blob file is overwritten, - // the correct content should be restored: - fs::write(blob3.to_abs_path(), b"bloblo").await?; - - let blob4 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?; - let blob4_content = fs::read(blob4.to_abs_path()).await?; - assert_eq!(blob4_content, b"blabla"); - } - - Ok(()) - } -} +mod blob_tests; diff --git a/src/blob/blob_tests.rs b/src/blob/blob_tests.rs new file mode 100644 index 000000000..b9e766354 --- /dev/null +++ b/src/blob/blob_tests.rs @@ -0,0 +1,810 @@ +use std::time::Duration; + +use super::*; +use crate::message::{Message, Viewtype}; +use crate::sql; +use crate::test_utils::{self, TestContext}; +use crate::tools::SystemTime; + +fn check_image_size(path: impl AsRef, width: u32, height: u32) -> image::DynamicImage { + tokio::task::block_in_place(move || { + let img = ImageReader::open(path) + .expect("failed to open image") + .with_guessed_format() + .expect("failed to guess format") + .decode() + .expect("failed to decode image"); + assert_eq!(img.width(), width, "invalid width"); + assert_eq!(img.height(), height, "invalid height"); + img + }) +} + +const FILE_BYTES: &[u8] = b"hello"; +const FILE_DEDUPLICATED: &str = "ea8f163db38682925e4491c5e58d4bb.txt"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create() { + let t = TestContext::new().await; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); + let fname = t.get_blobdir().join(FILE_DEDUPLICATED); + let data = fs::read(fname).await.unwrap(); + assert_eq!(data, FILE_BYTES); + assert_eq!(blob.as_name(), format!("$BLOBDIR/{FILE_DEDUPLICATED}")); + assert_eq!(blob.to_abs_path(), t.get_blobdir().join(FILE_DEDUPLICATED)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_lowercase_ext() { + let t = TestContext::new().await; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.TXT").unwrap(); + assert!( + blob.as_name().ends_with(".txt"), + "Blob {blob:?} should end with .txt" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_as_file_name() { + let t = TestContext::new().await; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); + assert_eq!(blob.as_file_name(), FILE_DEDUPLICATED); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_as_rel_path() { + let t = TestContext::new().await; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); + assert_eq!(blob.as_rel_path(), Path::new(FILE_DEDUPLICATED)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_suffix() { + let t = TestContext::new().await; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); + assert_eq!(blob.suffix(), Some("txt")); + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "bar").unwrap(); + assert_eq!(blob.suffix(), None); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_dup() { + let t = TestContext::new().await; + BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.txt").unwrap(); + let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED); + assert!(foo_path.exists()); + BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.txt").unwrap(); + let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap(); + while let Ok(Some(dirent)) = dir.next_entry().await { + let fname = dirent.file_name(); + if fname == foo_path.file_name().unwrap() { + assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES); + } else { + let name = fname.to_str().unwrap(); + assert!(name.ends_with(".txt")); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_double_ext() { + let t = TestContext::new().await; + BlobObject::create_and_deduplicate_from_bytes(&t, FILE_BYTES, "foo.tar.gz").unwrap(); + let foo_path = t.get_blobdir().join(FILE_DEDUPLICATED).with_extension("gz"); + assert!(foo_path.exists()); + BlobObject::create_and_deduplicate_from_bytes(&t, b"world", "foo.tar.gz").unwrap(); + let mut dir = fs::read_dir(t.get_blobdir()).await.unwrap(); + while let Ok(Some(dirent)) = dir.next_entry().await { + let fname = dirent.file_name(); + if fname == foo_path.file_name().unwrap() { + assert_eq!(fs::read(&foo_path).await.unwrap(), FILE_BYTES); + } else { + let name = fname.to_str().unwrap(); + println!("{name}"); + assert_eq!(name.starts_with("foo"), false); + assert_eq!(name.ends_with(".tar.gz"), false); + assert!(name.ends_with(".gz")); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_long_names() { + let t = TestContext::new().await; + let s = format!("file.{}", "a".repeat(100)); + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"data", &s).unwrap(); + let blobname = blob.as_name().split('/').last().unwrap(); + assert!(blobname.len() < 70); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_and_copy() { + let t = TestContext::new().await; + let src = t.dir.path().join("src"); + fs::write(&src, b"boo").await.unwrap(); + let blob = BlobObject::create_and_copy(&t, src.as_ref()).await.unwrap(); + assert_eq!(blob.as_name(), "$BLOBDIR/src"); + let data = fs::read(blob.to_abs_path()).await.unwrap(); + assert_eq!(data, b"boo"); + + let whoops = t.dir.path().join("whoops"); + assert!(BlobObject::create_and_copy(&t, whoops.as_ref()) + .await + .is_err()); + let whoops = t.get_blobdir().join("whoops"); + assert!(!whoops.exists()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_from_path() { + let t = TestContext::new().await; + + let src_ext = t.dir.path().join("external"); + fs::write(&src_ext, b"boo").await.unwrap(); + let blob = BlobObject::new_from_path(&t, src_ext.as_ref()) + .await + .unwrap(); + assert_eq!(blob.as_name(), "$BLOBDIR/external"); + let data = fs::read(blob.to_abs_path()).await.unwrap(); + assert_eq!(data, b"boo"); + + let src_int = t.get_blobdir().join("internal"); + fs::write(&src_int, b"boo").await.unwrap(); + let blob = BlobObject::new_from_path(&t, &src_int).await.unwrap(); + assert_eq!(blob.as_name(), "$BLOBDIR/internal"); + let data = fs::read(blob.to_abs_path()).await.unwrap(); + assert_eq!(data, b"boo"); +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_from_name_long() { + let t = TestContext::new().await; + let src_ext = t.dir.path().join("autocrypt-setup-message-4137848473.html"); + fs::write(&src_ext, b"boo").await.unwrap(); + let blob = BlobObject::new_from_path(&t, src_ext.as_ref()) + .await + .unwrap(); + assert_eq!( + blob.as_name(), + "$BLOBDIR/autocrypt-setup-message-4137848473.html" + ); +} + +#[test] +fn test_is_blob_name() { + assert!(BlobObject::is_acceptible_blob_name("foo")); + assert!(BlobObject::is_acceptible_blob_name("foo.txt")); + assert!(BlobObject::is_acceptible_blob_name("f".repeat(128))); + assert!(!BlobObject::is_acceptible_blob_name("foo/bar")); + assert!(!BlobObject::is_acceptible_blob_name("foo\\bar")); + assert!(!BlobObject::is_acceptible_blob_name("foo\x00bar")); +} + +#[test] +fn test_sanitise_name() { + let (stem, ext) = BlobObject::sanitise_name("Я ЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯЯ.txt"); + assert_eq!(ext, ".txt"); + assert!(!stem.is_empty()); + + // the extensions are kept together as between stem and extension a number may be added - + // and `foo.tar.gz` should become `foo-1234.tar.gz` and not `foo.tar-1234.gz` + let (stem, ext) = BlobObject::sanitise_name("wot.tar.gz"); + assert_eq!(stem, "wot"); + assert_eq!(ext, ".tar.gz"); + + let (stem, ext) = BlobObject::sanitise_name(".foo.bar"); + assert_eq!(stem, ""); + assert_eq!(ext, ".foo.bar"); + + let (stem, ext) = BlobObject::sanitise_name("foo?.bar"); + assert!(stem.contains("foo")); + assert!(!stem.contains('?')); + assert_eq!(ext, ".bar"); + + let (stem, ext) = BlobObject::sanitise_name("no-extension"); + assert_eq!(stem, "no-extension"); + assert_eq!(ext, ""); + + let (stem, ext) = BlobObject::sanitise_name("path/ignored\\this: is* forbidden?.c"); + assert_eq!(ext, ".c"); + assert!(!stem.contains("path")); + assert!(!stem.contains("ignored")); + assert!(stem.contains("this")); + assert!(stem.contains("forbidden")); + assert!(!stem.contains('/')); + assert!(!stem.contains('\\')); + assert!(!stem.contains(':')); + assert!(!stem.contains('*')); + assert!(!stem.contains('?')); + + let (stem, ext) = BlobObject::sanitise_name( + "file.with_lots_of_characters_behind_point_and_double_ending.tar.gz", + ); + assert_eq!( + stem, + "file.with_lots_of_characters_behind_point_and_double_ending" + ); + assert_eq!(ext, ".tar.gz"); + + let (stem, ext) = BlobObject::sanitise_name("a. tar.tar.gz"); + assert_eq!(stem, "a. tar"); + assert_eq!(ext, ".tar.gz"); + + let (stem, ext) = BlobObject::sanitise_name("Guia_uso_GNB (v0.8).pdf"); + assert_eq!(stem, "Guia_uso_GNB (v0.8)"); + assert_eq!(ext, ".pdf"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_add_white_bg() { + let t = TestContext::new().await; + let bytes0 = include_bytes!("../../test-data/image/logo.png").as_slice(); + let bytes1 = include_bytes!("../../test-data/image/avatar900x900.png").as_slice(); + for (bytes, color) in [ + (bytes0, [255u8, 255, 255, 255]), + (bytes1, [253u8, 198, 0, 255]), + ] { + let avatar_src = t.dir.path().join("avatar.png"); + fs::write(&avatar_src, bytes).await.unwrap(); + + let mut blob = BlobObject::new_from_path(&t, &avatar_src).await.unwrap(); + let img_wh = 128; + let maybe_sticker = &mut false; + let strict_limits = true; + blob.recode_to_size(&t, None, maybe_sticker, img_wh, 20_000, strict_limits) + .unwrap(); + tokio::task::block_in_place(move || { + let img = ImageReader::open(blob.to_abs_path()) + .unwrap() + .with_guessed_format() + .unwrap() + .decode() + .unwrap(); + assert!(img.width() == img_wh); + assert!(img.height() == img_wh); + assert_eq!(img.get_pixel(0, 0), Rgba(color)); + }); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_selfavatar_outside_blobdir() { + async fn file_size(path_buf: &Path) -> u64 { + fs::metadata(path_buf).await.unwrap().len() + } + + let t = TestContext::new().await; + let avatar_src = t.dir.path().join("avatar.jpg"); + let avatar_bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg"); + fs::write(&avatar_src, avatar_bytes).await.unwrap(); + t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) + .await + .unwrap(); + let avatar_blob = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); + let avatar_path = Path::new(&avatar_blob); + assert!( + avatar_blob.ends_with("d98cd30ed8f2129bf3968420208849d.jpg"), + "The avatar filename should be its hash, put instead it's {avatar_blob}" + ); + let scaled_avatar_size = file_size(avatar_path).await; + assert!(scaled_avatar_size < avatar_bytes.len() as u64); + + check_image_size(avatar_src, 1000, 1000); + check_image_size( + &avatar_blob, + constants::BALANCED_AVATAR_SIZE, + constants::BALANCED_AVATAR_SIZE, + ); + + let mut blob = BlobObject::new_from_path(&t, avatar_path).await.unwrap(); + let maybe_sticker = &mut false; + let strict_limits = true; + blob.recode_to_size(&t, None, maybe_sticker, 1000, 3000, strict_limits) + .unwrap(); + let new_file_size = file_size(&blob.to_abs_path()).await; + assert!(new_file_size <= 3000); + assert!(new_file_size > 2000); + // The new file should be smaller: + assert!(new_file_size < scaled_avatar_size); + // And the original file should not be touched: + assert_eq!(file_size(avatar_path).await, scaled_avatar_size); + tokio::task::block_in_place(move || { + let img = ImageReader::open(blob.to_abs_path()) + .unwrap() + .with_guessed_format() + .unwrap() + .decode() + .unwrap(); + assert!(img.width() > 130); + assert_eq!(img.width(), img.height()); + }); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_selfavatar_in_blobdir() { + let t = TestContext::new().await; + let avatar_src = t.get_blobdir().join("avatar.png"); + fs::write(&avatar_src, test_utils::AVATAR_900x900_BYTES) + .await + .unwrap(); + + check_image_size(&avatar_src, 900, 900); + + t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) + .await + .unwrap(); + let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); + assert!( + avatar_cfg.ends_with("9e7f409ac5c92b942cc4f31cee2770a.png"), + "Avatar file name {avatar_cfg} should end with its hash" + ); + + check_image_size( + avatar_cfg, + constants::BALANCED_AVATAR_SIZE, + constants::BALANCED_AVATAR_SIZE, + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_selfavatar_copy_without_recode() { + let t = TestContext::new().await; + let avatar_src = t.dir.path().join("avatar.png"); + let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png"); + fs::write(&avatar_src, avatar_bytes).await.unwrap(); + let avatar_blob = t.get_blobdir().join("e9b6c7a78aa2e4f415644f55a553e73.png"); + assert!(!avatar_blob.exists()); + t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) + .await + .unwrap(); + assert!(avatar_blob.exists()); + assert_eq!( + fs::metadata(&avatar_blob).await.unwrap().len(), + avatar_bytes.len() as u64 + ); + let avatar_cfg = t.get_config(Config::Selfavatar).await.unwrap(); + assert_eq!(avatar_cfg, avatar_blob.to_str().map(|s| s.to_string())); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_1() { + let bytes = include_bytes!("../../test-data/image/avatar1000x1000.jpg"); + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "0", + bytes, + extension: "jpg", + has_exif: true, + original_width: 1000, + original_height: 1000, + compressed_width: 1000, + compressed_height: 1000, + ..Default::default() + } + .test() + .await + .unwrap(); + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "1", + bytes, + extension: "jpg", + has_exif: true, + original_width: 1000, + original_height: 1000, + compressed_width: 1000, + compressed_height: 1000, + ..Default::default() + } + .test() + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_2() { + // The "-rotated" files are rotated by 270 degrees using the Exif metadata + let bytes = include_bytes!("../../test-data/image/rectangle2000x1800-rotated.jpg"); + let img_rotated = SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "0", + bytes, + extension: "jpg", + has_exif: true, + original_width: 2000, + original_height: 1800, + orientation: 270, + compressed_width: 1800, + compressed_height: 2000, + ..Default::default() + } + .test() + .await + .unwrap(); + assert_correct_rotation(&img_rotated); + + let mut buf = Cursor::new(vec![]); + img_rotated.write_to(&mut buf, ImageFormat::Jpeg).unwrap(); + let bytes = buf.into_inner(); + + let img_rotated = SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "1", + bytes: &bytes, + extension: "jpg", + original_width: 1800, + original_height: 2000, + compressed_width: 1800, + compressed_height: 2000, + ..Default::default() + } + .test() + .await + .unwrap(); + assert_correct_rotation(&img_rotated); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_balanced_png() { + let bytes = include_bytes!("../../test-data/image/screenshot.png"); + + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "0", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: 1920, + compressed_height: 1080, + ..Default::default() + } + .test() + .await + .unwrap(); + + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "1", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: constants::WORSE_IMAGE_SIZE, + compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920, + ..Default::default() + } + .test() + .await + .unwrap(); + + SendImageCheckMediaquality { + viewtype: Viewtype::File, + media_quality_config: "1", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: 1920, + compressed_height: 1080, + ..Default::default() + } + .test() + .await + .unwrap(); + + SendImageCheckMediaquality { + viewtype: Viewtype::File, + media_quality_config: "1", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: 1920, + compressed_height: 1080, + set_draft: true, + ..Default::default() + } + .test() + .await + .unwrap(); + + // This will be sent as Image, see [`BlobObject::maybe_sticker`] for explanation. + SendImageCheckMediaquality { + viewtype: Viewtype::Sticker, + media_quality_config: "0", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: 1920, + compressed_height: 1080, + ..Default::default() + } + .test() + .await + .unwrap(); +} + +/// Tests that RGBA PNG can be recoded into JPEG +/// by dropping alpha channel. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_rgba_png_to_jpeg() { + let bytes = include_bytes!("../../test-data/image/screenshot-rgba.png"); + + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "1", + bytes, + extension: "png", + original_width: 1920, + original_height: 1080, + compressed_width: constants::WORSE_IMAGE_SIZE, + compressed_height: constants::WORSE_IMAGE_SIZE * 1080 / 1920, + ..Default::default() + } + .test() + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_recode_image_huge_jpg() { + let bytes = include_bytes!("../../test-data/image/screenshot.jpg"); + SendImageCheckMediaquality { + viewtype: Viewtype::Image, + media_quality_config: "0", + bytes, + extension: "jpg", + has_exif: true, + original_width: 1920, + original_height: 1080, + compressed_width: constants::BALANCED_IMAGE_SIZE, + compressed_height: constants::BALANCED_IMAGE_SIZE * 1080 / 1920, + ..Default::default() + } + .test() + .await + .unwrap(); +} + +fn assert_correct_rotation(img: &DynamicImage) { + // The test images are black in the bottom left corner after correctly applying + // the EXIF orientation + + let [luma] = img.get_pixel(10, 10).to_luma().0; + assert_eq!(luma, 255); + let [luma] = img.get_pixel(img.width() - 10, 10).to_luma().0; + assert_eq!(luma, 255); + let [luma] = img + .get_pixel(img.width() - 10, img.height() - 10) + .to_luma() + .0; + assert_eq!(luma, 255); + let [luma] = img.get_pixel(10, img.height() - 10).to_luma().0; + assert_eq!(luma, 0); +} + +#[derive(Default)] +struct SendImageCheckMediaquality<'a> { + pub(crate) viewtype: Viewtype, + pub(crate) media_quality_config: &'a str, + pub(crate) bytes: &'a [u8], + pub(crate) extension: &'a str, + pub(crate) has_exif: bool, + pub(crate) original_width: u32, + pub(crate) original_height: u32, + pub(crate) orientation: i32, + pub(crate) compressed_width: u32, + pub(crate) compressed_height: u32, + pub(crate) set_draft: bool, +} + +impl SendImageCheckMediaquality<'_> { + pub(crate) async fn test(self) -> anyhow::Result { + let viewtype = self.viewtype; + let media_quality_config = self.media_quality_config; + let bytes = self.bytes; + let extension = self.extension; + let has_exif = self.has_exif; + let original_width = self.original_width; + let original_height = self.original_height; + let orientation = self.orientation; + let compressed_width = self.compressed_width; + let compressed_height = self.compressed_height; + let set_draft = self.set_draft; + + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + alice + .set_config(Config::MediaQuality, Some(media_quality_config)) + .await?; + let file = alice.get_blobdir().join("file").with_extension(extension); + let file_name = format!("file.{extension}"); + + fs::write(&file, &bytes) + .await + .context("failed to write file")?; + check_image_size(&file, original_width, original_height); + + let (_, exif) = image_metadata(&std::fs::File::open(&file)?)?; + if has_exif { + let exif = exif.unwrap(); + assert_eq!(exif_orientation(&exif, &alice), orientation); + } else { + assert!(exif.is_none()); + } + + let mut msg = Message::new(viewtype); + msg.set_file_and_deduplicate(&alice, &file, Some(&file_name), None)?; + let chat = alice.create_chat(&bob).await; + if set_draft { + chat.id.set_draft(&alice, Some(&mut msg)).await.unwrap(); + msg = chat.id.get_draft(&alice).await.unwrap().unwrap(); + assert_eq!(msg.get_viewtype(), Viewtype::File); + } + let sent = alice.send_msg(chat.id, &mut msg).await; + let alice_msg = alice.get_last_msg().await; + assert_eq!(alice_msg.get_width() as u32, compressed_width); + assert_eq!(alice_msg.get_height() as u32, compressed_height); + let file_saved = alice + .get_blobdir() + .join("saved-".to_string() + &alice_msg.get_filename().unwrap()); + alice_msg.save_file(&alice, &file_saved).await?; + check_image_size(file_saved, compressed_width, compressed_height); + + let bob_msg = bob.recv_msg(&sent).await; + assert_eq!(bob_msg.get_viewtype(), Viewtype::Image); + assert_eq!(bob_msg.get_width() as u32, compressed_width); + assert_eq!(bob_msg.get_height() as u32, compressed_height); + let file_saved = bob + .get_blobdir() + .join("saved-".to_string() + &bob_msg.get_filename().unwrap()); + bob_msg.save_file(&bob, &file_saved).await?; + if viewtype == Viewtype::File { + assert_eq!(file_saved.extension().unwrap(), extension); + let bytes1 = fs::read(&file_saved).await?; + assert_eq!(&bytes1, bytes); + } + + let (_, exif) = image_metadata(&std::fs::File::open(&file_saved)?)?; + assert!(exif.is_none()); + + let img = check_image_size(file_saved, compressed_width, compressed_height); + Ok(img) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_send_big_gif_as_image() -> Result<()> { + let bytes = include_bytes!("../../test-data/image/screenshot.gif"); + let (width, height) = (1920u32, 1080u32); + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + alice + .set_config( + Config::MediaQuality, + Some(&(MediaQuality::Worse as i32).to_string()), + ) + .await?; + let file = alice.get_blobdir().join("file").with_extension("gif"); + fs::write(&file, &bytes) + .await + .context("failed to write file")?; + let mut msg = Message::new(Viewtype::Image); + msg.set_file_and_deduplicate(&alice, &file, Some("file.gif"), None)?; + let chat = alice.create_chat(&bob).await; + let sent = alice.send_msg(chat.id, &mut msg).await; + let bob_msg = bob.recv_msg(&sent).await; + // DC must detect the image as GIF and send it w/o reencoding. + assert_eq!(bob_msg.get_viewtype(), Viewtype::Gif); + assert_eq!(bob_msg.get_width() as u32, width); + assert_eq!(bob_msg.get_height() as u32, height); + let file_saved = bob + .get_blobdir() + .join("saved-".to_string() + &bob_msg.get_filename().unwrap()); + bob_msg.save_file(&bob, &file_saved).await?; + let (file_size, _) = image_metadata(&std::fs::File::open(&file_saved)?)?; + assert_eq!(file_size, bytes.len() as u64); + check_image_size(file_saved, width, height); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_send_gif_as_sticker() -> Result<()> { + let bytes = include_bytes!("../../test-data/image/image100x50.gif"); + let alice = &TestContext::new_alice().await; + let file = alice.get_blobdir().join("file").with_extension("gif"); + fs::write(&file, &bytes) + .await + .context("failed to write file")?; + let mut msg = Message::new(Viewtype::Sticker); + msg.set_file_and_deduplicate(alice, &file, None, None)?; + let chat = alice.get_self_chat().await; + let sent = alice.send_msg(chat.id, &mut msg).await; + let msg = Message::load_from_db(alice, sent.sender_msg_id).await?; + // Message::force_sticker() wasn't used, still Viewtype::Sticker is preserved because of the + // extension. + assert_eq!(msg.get_viewtype(), Viewtype::Sticker); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_and_deduplicate() -> Result<()> { + let t = TestContext::new().await; + + let path = t.get_blobdir().join("anyfile.dat"); + fs::write(&path, b"bla").await?; + let blob = BlobObject::create_and_deduplicate(&t, &path, &path)?; + assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f.dat"); + assert_eq!(path.exists(), false); + + assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla"); + + fs::write(&path, b"bla").await?; + let blob2 = BlobObject::create_and_deduplicate(&t, &path, &path)?; + assert_eq!(blob2.name, blob.name); + + let path_outside_blobdir = t.dir.path().join("anyfile.dat"); + fs::write(&path_outside_blobdir, b"bla").await?; + let blob3 = + BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?; + assert!(path_outside_blobdir.exists()); + assert_eq!(blob3.name, blob.name); + + fs::write(&path, b"blabla").await?; + let blob4 = BlobObject::create_and_deduplicate(&t, &path, &path)?; + assert_ne!(blob4.name, blob.name); + + fs::remove_dir_all(t.get_blobdir()).await?; + let blob5 = + BlobObject::create_and_deduplicate(&t, &path_outside_blobdir, &path_outside_blobdir)?; + assert_eq!(blob5.name, blob.name); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_create_and_deduplicate_from_bytes() -> Result<()> { + let t = TestContext::new().await; + + fs::remove_dir(t.get_blobdir()).await?; + let blob = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?; + assert_eq!(blob.name, "$BLOBDIR/ce940175885d7b78f7b7e9f1396611f"); + + assert_eq!(fs::read(&blob.to_abs_path()).await?, b"bla"); + let modified1 = blob.to_abs_path().metadata()?.modified()?; + + // Test that the modification time of the file is updated when a new file is created + // so that it's not deleted during housekeeping. + // We can't use SystemTime::shift() here because file creation uses the actual OS time, + // which we can't mock from our code. + tokio::time::sleep(Duration::from_millis(1100)).await; + + let blob2 = BlobObject::create_and_deduplicate_from_bytes(&t, b"bla", "file")?; + assert_eq!(blob2.name, blob.name); + + let modified2 = blob.to_abs_path().metadata()?.modified()?; + assert_ne!(modified1, modified2); + sql::housekeeping(&t).await?; + assert!(blob2.to_abs_path().exists()); + + // If we do shift the time by more than 1h, the blob file will be deleted during housekeeping: + SystemTime::shift(Duration::from_secs(65 * 60)); + sql::housekeeping(&t).await?; + assert_eq!(blob2.to_abs_path().exists(), false); + + let blob3 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?; + assert_ne!(blob3.name, blob.name); + + { + // If something goes wrong and the blob file is overwritten, + // the correct content should be restored: + fs::write(blob3.to_abs_path(), b"bloblo").await?; + + let blob4 = BlobObject::create_and_deduplicate_from_bytes(&t, b"blabla", "file")?; + let blob4_content = fs::read(blob4.to_abs_path()).await?; + assert_eq!(blob4_content, b"blabla"); + } + + Ok(()) +} diff --git a/src/context.rs b/src/context.rs index 6dc2d66e3..4b7fa9e60 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1480,654 +1480,4 @@ pub fn get_version_str() -> &'static str { } #[cfg(test)] -mod tests { - use anyhow::Context as _; - use strum::IntoEnumIterator; - use tempfile::tempdir; - - use super::*; - use crate::chat::{get_chat_contacts, get_chat_msgs, send_msg, set_muted, Chat, MuteDuration}; - use crate::chatlist::Chatlist; - use crate::constants::Chattype; - use crate::mimeparser::SystemMessage; - use crate::receive_imf::receive_imf; - use crate::test_utils::{get_chat_msg, TestContext}; - use crate::tools::{create_outgoing_rfc724_mid, SystemTime}; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_wrong_db() -> Result<()> { - let tmp = tempfile::tempdir()?; - let dbfile = tmp.path().join("db.sqlite"); - tokio::fs::write(&dbfile, b"123").await?; - let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await?; - - // Broken database is indistinguishable from encrypted one. - assert_eq!(res.is_open().await, false); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_fresh_msgs() { - let t = TestContext::new().await; - let fresh = t.get_fresh_msgs().await.unwrap(); - assert!(fresh.is_empty()) - } - - async fn receive_msg(t: &TestContext, chat: &Chat) { - let members = get_chat_contacts(t, chat.id).await.unwrap(); - let contact = Contact::get_by_id(t, *members.first().unwrap()) - .await - .unwrap(); - let msg = format!( - "From: {}\n\ - To: alice@example.org\n\ - Message-ID: <{}>\n\ - Chat-Version: 1.0\n\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello\n", - contact.get_addr(), - create_outgoing_rfc724_mid() - ); - println!("{msg}"); - receive_imf(t, msg.as_bytes(), false).await.unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_fresh_msgs_and_muted_chats() { - // receive various mails in 3 chats - let t = TestContext::new_alice().await; - let bob = t.create_chat_with_contact("", "bob@g.it").await; - let claire = t.create_chat_with_contact("", "claire@g.it").await; - let dave = t.create_chat_with_contact("", "dave@g.it").await; - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); - - receive_msg(&t, &bob).await; - assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1); - assert_eq!(bob.id.get_fresh_msg_cnt(&t).await.unwrap(), 1); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); - - receive_msg(&t, &claire).await; - receive_msg(&t, &claire).await; - assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 2); - assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 3); - - receive_msg(&t, &dave).await; - receive_msg(&t, &dave).await; - receive_msg(&t, &dave).await; - assert_eq!(get_chat_msgs(&t, dave.id).await.unwrap().len(), 3); - assert_eq!(dave.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); - - // mute one of the chats - set_muted(&t, claire.id, MuteDuration::Forever) - .await - .unwrap(); - assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 4); // muted claires messages are no longer counted - - // receive more messages - receive_msg(&t, &bob).await; - receive_msg(&t, &claire).await; - receive_msg(&t, &dave).await; - assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 3); - assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); // muted claire is not counted - - // unmute claire again - set_muted(&t, claire.id, MuteDuration::NotMuted) - .await - .unwrap(); - assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 9); // claire is counted again - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_fresh_msgs_and_muted_until() { - let t = TestContext::new_alice().await; - let bob = t.create_chat_with_contact("", "bob@g.it").await; - receive_msg(&t, &bob).await; - assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1); - - // chat is unmuted by default, here and in the following assert(), - // we check mainly that the SQL-statements in is_muted() and get_fresh_msgs() - // have the same view to the database. - assert!(!bob.is_muted()); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); - - // test get_fresh_msgs() with mute_until in the future - set_muted( - &t, - bob.id, - MuteDuration::Until(SystemTime::now() + Duration::from_secs(3600)), - ) - .await - .unwrap(); - let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); - assert!(bob.is_muted()); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); - - // to test get_fresh_msgs() with mute_until in the past, - // we need to modify the database directly - t.sql - .execute( - "UPDATE chats SET muted_until=? WHERE id=?;", - (time() - 3600, bob.id), - ) - .await - .unwrap(); - let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); - assert!(!bob.is_muted()); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); - - // test get_fresh_msgs() with "forever" mute_until - set_muted(&t, bob.id, MuteDuration::Forever).await.unwrap(); - let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); - assert!(bob.is_muted()); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); - - // to test get_fresh_msgs() with invalid mute_until (everything < -1), - // that results in "muted forever" by definition. - t.sql - .execute("UPDATE chats SET muted_until=-2 WHERE id=?;", (bob.id,)) - .await - .unwrap(); - let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); - assert!(!bob.is_muted()); - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_muted_context() -> Result<()> { - let t = TestContext::new_alice().await; - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); - t.set_config(Config::IsMuted, Some("1")).await?; - let chat = t.create_chat_with_contact("", "bob@g.it").await; - receive_msg(&t, &chat).await; - - // muted contexts should still show dimmed badge counters eg. in the sidebars, - // (same as muted chats show dimmed badge counters in the chatlist) - // therefore the fresh messages count should not be affected. - assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_blobdir_exists() { - let tmp = tempfile::tempdir().unwrap(); - let dbfile = tmp.path().join("db.sqlite"); - Context::new(&dbfile, 1, Events::new(), StockStrings::new()) - .await - .unwrap(); - let blobdir = tmp.path().join("db.sqlite-blobs"); - assert!(blobdir.is_dir()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_wrong_blogdir() { - let tmp = tempfile::tempdir().unwrap(); - let dbfile = tmp.path().join("db.sqlite"); - let blobdir = tmp.path().join("db.sqlite-blobs"); - tokio::fs::write(&blobdir, b"123").await.unwrap(); - let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await; - assert!(res.is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_sqlite_parent_not_exists() { - let tmp = tempfile::tempdir().unwrap(); - let subdir = tmp.path().join("subdir"); - let dbfile = subdir.join("db.sqlite"); - let dbfile2 = dbfile.clone(); - Context::new(&dbfile, 1, Events::new(), StockStrings::new()) - .await - .unwrap(); - assert!(subdir.is_dir()); - assert!(dbfile2.is_file()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_with_empty_blobdir() { - let tmp = tempfile::tempdir().unwrap(); - let dbfile = tmp.path().join("db.sqlite"); - let blobdir = PathBuf::new(); - let res = Context::with_blobdir( - dbfile, - blobdir, - 1, - Events::new(), - StockStrings::new(), - Default::default(), - ); - assert!(res.is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_with_blobdir_not_exists() { - let tmp = tempfile::tempdir().unwrap(); - let dbfile = tmp.path().join("db.sqlite"); - let blobdir = tmp.path().join("blobs"); - let res = Context::with_blobdir( - dbfile, - blobdir, - 1, - Events::new(), - StockStrings::new(), - Default::default(), - ); - assert!(res.is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn no_crashes_on_context_deref() { - let t = TestContext::new().await; - std::mem::drop(t); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_info() { - let t = TestContext::new().await; - - let info = t.get_info().await.unwrap(); - assert!(info.contains_key("database_dir")); - } - - #[test] - fn test_get_info_no_context() { - let info = get_info(); - assert!(info.contains_key("deltachat_core_version")); - assert!(!info.contains_key("database_dir")); - assert_eq!(info.get("level").unwrap(), "awesome"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_info_completeness() { - // For easier debugging, - // get_info() shall return all important information configurable by the Config-values. - // - // There are exceptions for Config-values considered to be unimportant, - // too sensitive or summarized in another item. - let skip_from_get_info = vec![ - "addr", - "displayname", - "imap_certificate_checks", - "mail_server", - "mail_user", - "mail_pw", - "mail_port", - "mail_security", - "notify_about_wrong_pw", - "self_reporting_id", - "selfstatus", - "send_server", - "send_user", - "send_pw", - "send_port", - "send_security", - "server_flags", - "skip_start_messages", - "smtp_certificate_checks", - "proxy_url", // May contain passwords, don't leak it to the logs. - "socks5_enabled", // SOCKS5 options are deprecated. - "socks5_host", - "socks5_port", - "socks5_user", - "socks5_password", - "key_id", - "webxdc_integration", - "device_token", - "encrypted_device_token", - ]; - let t = TestContext::new().await; - let info = t.get_info().await.unwrap(); - for key in Config::iter() { - let key: String = key.to_string(); - if !skip_from_get_info.contains(&&*key) - && !key.starts_with("configured") - && !key.starts_with("sys.") - { - assert!( - info.contains_key(&*key), - "'{key}' missing in get_info() output" - ); - } - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_search_msgs() -> Result<()> { - let alice = TestContext::new_alice().await; - let self_talk = ChatId::create_for_contact(&alice, ContactId::SELF).await?; - let chat = alice - .create_chat_with_contact("Bob", "bob@example.org") - .await; - - // Global search finds nothing. - let res = alice.search_msgs(None, "foo").await?; - assert!(res.is_empty()); - - // Search in chat with Bob finds nothing. - let res = alice.search_msgs(Some(chat.id), "foo").await?; - assert!(res.is_empty()); - - // Add messages to chat with Bob. - let mut msg1 = Message::new_text("foobar".to_string()); - send_msg(&alice, chat.id, &mut msg1).await?; - - let mut msg2 = Message::new_text("barbaz".to_string()); - send_msg(&alice, chat.id, &mut msg2).await?; - - alice.send_text(chat.id, "Δ-Chat").await; - - // Global search with a part of text finds the message. - let res = alice.search_msgs(None, "ob").await?; - assert_eq!(res.len(), 1); - - // Global search for "bar" matches both "foobar" and "barbaz". - let res = alice.search_msgs(None, "bar").await?; - assert_eq!(res.len(), 2); - - // Message added later is returned first. - assert_eq!(res.first(), Some(&msg2.id)); - assert_eq!(res.get(1), Some(&msg1.id)); - - // Search is case-insensitive. - for chat_id in [None, Some(chat.id)] { - let res = alice.search_msgs(chat_id, "δ-chat").await?; - assert_eq!(res.len(), 1); - } - - // Global search with longer text does not find any message. - let res = alice.search_msgs(None, "foobarbaz").await?; - assert!(res.is_empty()); - - // Search for random string finds nothing. - let res = alice.search_msgs(None, "abc").await?; - assert!(res.is_empty()); - - // Search in chat with Bob finds the message. - let res = alice.search_msgs(Some(chat.id), "foo").await?; - assert_eq!(res.len(), 1); - - // Search in Saved Messages does not find the message. - let res = alice.search_msgs(Some(self_talk), "foo").await?; - assert!(res.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_search_unaccepted_requests() -> Result<()> { - let t = TestContext::new_alice().await; - receive_imf( - &t, - b"From: BobBar \n\ - To: alice@example.org\n\ - Subject: foo\n\ - Message-ID: \n\ - Chat-Version: 1.0\n\ - Date: Tue, 25 Oct 2022 13:37:00 +0000\n\ - \n\ - hello bob, foobar test!\n", - false, - ) - .await?; - let chat_id = t.get_last_msg().await.get_chat_id(); - let chat = Chat::load_from_db(&t, chat_id).await?; - assert_eq!(chat.get_type(), Chattype::Single); - assert!(chat.is_contact_request()); - - assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1); - assert_eq!( - Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), - 1 - ); - assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1); - assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1); - - chat_id.block(&t).await?; - - assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 0); - assert_eq!( - Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), - 0 - ); - assert_eq!(t.search_msgs(None, "foobar").await?.len(), 0); - assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 0); - - let contact_ids = get_chat_contacts(&t, chat_id).await?; - Contact::unblock(&t, *contact_ids.first().unwrap()).await?; - - assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1); - assert_eq!( - Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), - 1 - ); - assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1); - assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_limit_search_msgs() -> Result<()> { - let alice = TestContext::new_alice().await; - let chat = alice - .create_chat_with_contact("Bob", "bob@example.org") - .await; - - // Add 999 messages - let mut msg = Message::new_text("foobar".to_string()); - for _ in 0..999 { - send_msg(&alice, chat.id, &mut msg).await?; - } - let res = alice.search_msgs(None, "foo").await?; - assert_eq!(res.len(), 999); - - // Add one more message, no limit yet - send_msg(&alice, chat.id, &mut msg).await?; - let res = alice.search_msgs(None, "foo").await?; - assert_eq!(res.len(), 1000); - - // Add one more message, that one is truncated then - send_msg(&alice, chat.id, &mut msg).await?; - let res = alice.search_msgs(None, "foo").await?; - assert_eq!(res.len(), 1000); - - // In-chat should not be not limited - let res = alice.search_msgs(Some(chat.id), "foo").await?; - assert_eq!(res.len(), 1001); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_check_passphrase() -> Result<()> { - let dir = tempdir()?; - let dbfile = dir.path().join("db.sqlite"); - - let context = ContextBuilder::new(dbfile.clone()) - .with_id(1) - .build() - .await - .context("failed to create context")?; - assert_eq!(context.open("foo".to_string()).await?, true); - assert_eq!(context.is_open().await, true); - drop(context); - - let context = ContextBuilder::new(dbfile) - .with_id(2) - .build() - .await - .context("failed to create context")?; - assert_eq!(context.is_open().await, false); - assert_eq!(context.check_passphrase("bar".to_string()).await?, false); - assert_eq!(context.open("false".to_string()).await?, false); - assert_eq!(context.open("foo".to_string()).await?, true); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_context_change_passphrase() -> Result<()> { - let dir = tempdir()?; - let dbfile = dir.path().join("db.sqlite"); - - let context = ContextBuilder::new(dbfile) - .with_id(1) - .build() - .await - .context("failed to create context")?; - assert_eq!(context.open("foo".to_string()).await?, true); - assert_eq!(context.is_open().await, true); - - context - .set_config(Config::Addr, Some("alice@example.org")) - .await?; - - context - .change_passphrase("bar".to_string()) - .await - .context("Failed to change passphrase")?; - - assert_eq!( - context.get_config(Config::Addr).await?.unwrap(), - "alice@example.org" - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ongoing() -> Result<()> { - let context = TestContext::new().await; - - // No ongoing process allocated. - assert!(context.shall_stop_ongoing().await); - - let receiver = context.alloc_ongoing().await?; - - // Cannot allocate another ongoing process while the first one is running. - assert!(context.alloc_ongoing().await.is_err()); - - // Stop signal is not sent yet. - assert!(receiver.try_recv().is_err()); - - assert!(!context.shall_stop_ongoing().await); - - // Send the stop signal. - context.stop_ongoing().await; - - // Receive stop signal. - receiver.recv().await?; - - assert!(context.shall_stop_ongoing().await); - - // Ongoing process is still running even though stop signal was received, - // so another one cannot be allocated. - assert!(context.alloc_ongoing().await.is_err()); - - context.free_ongoing().await; - - // No ongoing process allocated, should have been stopped already. - assert!(context.shall_stop_ongoing().await); - - // Another ongoing process can be allocated now. - let _receiver = context.alloc_ongoing().await?; - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_next_msgs() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - - let alice_chat = alice.create_chat(&bob).await; - - assert!(alice.get_next_msgs().await?.is_empty()); - assert!(bob.get_next_msgs().await?.is_empty()); - - let sent_msg = alice.send_text(alice_chat.id, "Hi Bob").await; - let received_msg = bob.recv_msg(&sent_msg).await; - - let bob_next_msg_ids = bob.get_next_msgs().await?; - assert_eq!(bob_next_msg_ids.len(), 1); - assert_eq!(bob_next_msg_ids.first(), Some(&received_msg.id)); - - bob.set_config_u32(Config::LastMsgId, received_msg.id.to_u32()) - .await?; - assert!(bob.get_next_msgs().await?.is_empty()); - - // Next messages include self-sent messages. - let alice_next_msg_ids = alice.get_next_msgs().await?; - assert_eq!(alice_next_msg_ids.len(), 1); - assert_eq!(alice_next_msg_ids.first(), Some(&sent_msg.sender_msg_id)); - - alice - .set_config_u32(Config::LastMsgId, sent_msg.sender_msg_id.to_u32()) - .await?; - assert!(alice.get_next_msgs().await?.is_empty()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_draft_self_report() -> Result<()> { - let alice = TestContext::new_alice().await; - - let chat_id = alice.draft_self_report().await?; - let msg = get_chat_msg(&alice, chat_id, 0, 1).await; - assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled); - - let chat = Chat::load_from_db(&alice, chat_id).await?; - assert!(chat.is_protected()); - - let mut draft = chat_id.get_draft(&alice).await?.unwrap(); - assert!(draft.text.starts_with("core_version")); - - // Test that sending into the protected chat works: - let _sent = alice.send_msg(chat_id, &mut draft).await; - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_cache_is_cleared_when_io_is_started() -> Result<()> { - let alice = TestContext::new_alice().await; - assert_eq!( - alice.get_config(Config::ShowEmails).await?, - Some("2".to_string()) - ); - - // Change the config circumventing the cache - // This simulates what the notification plugin on iOS might do - // because it runs in a different process - alice - .sql - .execute( - "INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')", - (), - ) - .await?; - - // Alice's Delta Chat doesn't know about it yet: - assert_eq!( - alice.get_config(Config::ShowEmails).await?, - Some("2".to_string()) - ); - - // Starting IO will fail of course because no server settings are configured, - // but it should invalidate the caches: - alice.start_io().await; - - assert_eq!( - alice.get_config(Config::ShowEmails).await?, - Some("0".to_string()) - ); - - Ok(()) - } -} +mod context_tests; diff --git a/src/context/context_tests.rs b/src/context/context_tests.rs new file mode 100644 index 000000000..111311aca --- /dev/null +++ b/src/context/context_tests.rs @@ -0,0 +1,649 @@ +use anyhow::Context as _; +use strum::IntoEnumIterator; +use tempfile::tempdir; + +use super::*; +use crate::chat::{get_chat_contacts, get_chat_msgs, send_msg, set_muted, Chat, MuteDuration}; +use crate::chatlist::Chatlist; +use crate::constants::Chattype; +use crate::mimeparser::SystemMessage; +use crate::receive_imf::receive_imf; +use crate::test_utils::{get_chat_msg, TestContext}; +use crate::tools::{create_outgoing_rfc724_mid, SystemTime}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_wrong_db() -> Result<()> { + let tmp = tempfile::tempdir()?; + let dbfile = tmp.path().join("db.sqlite"); + tokio::fs::write(&dbfile, b"123").await?; + let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await?; + + // Broken database is indistinguishable from encrypted one. + assert_eq!(res.is_open().await, false); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_fresh_msgs() { + let t = TestContext::new().await; + let fresh = t.get_fresh_msgs().await.unwrap(); + assert!(fresh.is_empty()) +} + +async fn receive_msg(t: &TestContext, chat: &Chat) { + let members = get_chat_contacts(t, chat.id).await.unwrap(); + let contact = Contact::get_by_id(t, *members.first().unwrap()) + .await + .unwrap(); + let msg = format!( + "From: {}\n\ + To: alice@example.org\n\ + Message-ID: <{}>\n\ + Chat-Version: 1.0\n\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", + contact.get_addr(), + create_outgoing_rfc724_mid() + ); + println!("{msg}"); + receive_imf(t, msg.as_bytes(), false).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_fresh_msgs_and_muted_chats() { + // receive various mails in 3 chats + let t = TestContext::new_alice().await; + let bob = t.create_chat_with_contact("", "bob@g.it").await; + let claire = t.create_chat_with_contact("", "claire@g.it").await; + let dave = t.create_chat_with_contact("", "dave@g.it").await; + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); + + receive_msg(&t, &bob).await; + assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1); + assert_eq!(bob.id.get_fresh_msg_cnt(&t).await.unwrap(), 1); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); + + receive_msg(&t, &claire).await; + receive_msg(&t, &claire).await; + assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 2); + assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 3); + + receive_msg(&t, &dave).await; + receive_msg(&t, &dave).await; + receive_msg(&t, &dave).await; + assert_eq!(get_chat_msgs(&t, dave.id).await.unwrap().len(), 3); + assert_eq!(dave.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); + + // mute one of the chats + set_muted(&t, claire.id, MuteDuration::Forever) + .await + .unwrap(); + assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 2); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 4); // muted claires messages are no longer counted + + // receive more messages + receive_msg(&t, &bob).await; + receive_msg(&t, &claire).await; + receive_msg(&t, &dave).await; + assert_eq!(get_chat_msgs(&t, claire.id).await.unwrap().len(), 3); + assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 6); // muted claire is not counted + + // unmute claire again + set_muted(&t, claire.id, MuteDuration::NotMuted) + .await + .unwrap(); + assert_eq!(claire.id.get_fresh_msg_cnt(&t).await.unwrap(), 3); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 9); // claire is counted again +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_fresh_msgs_and_muted_until() { + let t = TestContext::new_alice().await; + let bob = t.create_chat_with_contact("", "bob@g.it").await; + receive_msg(&t, &bob).await; + assert_eq!(get_chat_msgs(&t, bob.id).await.unwrap().len(), 1); + + // chat is unmuted by default, here and in the following assert(), + // we check mainly that the SQL-statements in is_muted() and get_fresh_msgs() + // have the same view to the database. + assert!(!bob.is_muted()); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); + + // test get_fresh_msgs() with mute_until in the future + set_muted( + &t, + bob.id, + MuteDuration::Until(SystemTime::now() + Duration::from_secs(3600)), + ) + .await + .unwrap(); + let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); + assert!(bob.is_muted()); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); + + // to test get_fresh_msgs() with mute_until in the past, + // we need to modify the database directly + t.sql + .execute( + "UPDATE chats SET muted_until=? WHERE id=?;", + (time() - 3600, bob.id), + ) + .await + .unwrap(); + let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); + assert!(!bob.is_muted()); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); + + // test get_fresh_msgs() with "forever" mute_until + set_muted(&t, bob.id, MuteDuration::Forever).await.unwrap(); + let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); + assert!(bob.is_muted()); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); + + // to test get_fresh_msgs() with invalid mute_until (everything < -1), + // that results in "muted forever" by definition. + t.sql + .execute("UPDATE chats SET muted_until=-2 WHERE id=?;", (bob.id,)) + .await + .unwrap(); + let bob = Chat::load_from_db(&t, bob.id).await.unwrap(); + assert!(!bob.is_muted()); + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_muted_context() -> Result<()> { + let t = TestContext::new_alice().await; + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 0); + t.set_config(Config::IsMuted, Some("1")).await?; + let chat = t.create_chat_with_contact("", "bob@g.it").await; + receive_msg(&t, &chat).await; + + // muted contexts should still show dimmed badge counters eg. in the sidebars, + // (same as muted chats show dimmed badge counters in the chatlist) + // therefore the fresh messages count should not be affected. + assert_eq!(t.get_fresh_msgs().await.unwrap().len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_blobdir_exists() { + let tmp = tempfile::tempdir().unwrap(); + let dbfile = tmp.path().join("db.sqlite"); + Context::new(&dbfile, 1, Events::new(), StockStrings::new()) + .await + .unwrap(); + let blobdir = tmp.path().join("db.sqlite-blobs"); + assert!(blobdir.is_dir()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_wrong_blogdir() { + let tmp = tempfile::tempdir().unwrap(); + let dbfile = tmp.path().join("db.sqlite"); + let blobdir = tmp.path().join("db.sqlite-blobs"); + tokio::fs::write(&blobdir, b"123").await.unwrap(); + let res = Context::new(&dbfile, 1, Events::new(), StockStrings::new()).await; + assert!(res.is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sqlite_parent_not_exists() { + let tmp = tempfile::tempdir().unwrap(); + let subdir = tmp.path().join("subdir"); + let dbfile = subdir.join("db.sqlite"); + let dbfile2 = dbfile.clone(); + Context::new(&dbfile, 1, Events::new(), StockStrings::new()) + .await + .unwrap(); + assert!(subdir.is_dir()); + assert!(dbfile2.is_file()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_with_empty_blobdir() { + let tmp = tempfile::tempdir().unwrap(); + let dbfile = tmp.path().join("db.sqlite"); + let blobdir = PathBuf::new(); + let res = Context::with_blobdir( + dbfile, + blobdir, + 1, + Events::new(), + StockStrings::new(), + Default::default(), + ); + assert!(res.is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_with_blobdir_not_exists() { + let tmp = tempfile::tempdir().unwrap(); + let dbfile = tmp.path().join("db.sqlite"); + let blobdir = tmp.path().join("blobs"); + let res = Context::with_blobdir( + dbfile, + blobdir, + 1, + Events::new(), + StockStrings::new(), + Default::default(), + ); + assert!(res.is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_crashes_on_context_deref() { + let t = TestContext::new().await; + std::mem::drop(t); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_info() { + let t = TestContext::new().await; + + let info = t.get_info().await.unwrap(); + assert!(info.contains_key("database_dir")); +} + +#[test] +fn test_get_info_no_context() { + let info = get_info(); + assert!(info.contains_key("deltachat_core_version")); + assert!(!info.contains_key("database_dir")); + assert_eq!(info.get("level").unwrap(), "awesome"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_info_completeness() { + // For easier debugging, + // get_info() shall return all important information configurable by the Config-values. + // + // There are exceptions for Config-values considered to be unimportant, + // too sensitive or summarized in another item. + let skip_from_get_info = vec![ + "addr", + "displayname", + "imap_certificate_checks", + "mail_server", + "mail_user", + "mail_pw", + "mail_port", + "mail_security", + "notify_about_wrong_pw", + "self_reporting_id", + "selfstatus", + "send_server", + "send_user", + "send_pw", + "send_port", + "send_security", + "server_flags", + "skip_start_messages", + "smtp_certificate_checks", + "proxy_url", // May contain passwords, don't leak it to the logs. + "socks5_enabled", // SOCKS5 options are deprecated. + "socks5_host", + "socks5_port", + "socks5_user", + "socks5_password", + "key_id", + "webxdc_integration", + "device_token", + "encrypted_device_token", + ]; + let t = TestContext::new().await; + let info = t.get_info().await.unwrap(); + for key in Config::iter() { + let key: String = key.to_string(); + if !skip_from_get_info.contains(&&*key) + && !key.starts_with("configured") + && !key.starts_with("sys.") + { + assert!( + info.contains_key(&*key), + "'{key}' missing in get_info() output" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_search_msgs() -> Result<()> { + let alice = TestContext::new_alice().await; + let self_talk = ChatId::create_for_contact(&alice, ContactId::SELF).await?; + let chat = alice + .create_chat_with_contact("Bob", "bob@example.org") + .await; + + // Global search finds nothing. + let res = alice.search_msgs(None, "foo").await?; + assert!(res.is_empty()); + + // Search in chat with Bob finds nothing. + let res = alice.search_msgs(Some(chat.id), "foo").await?; + assert!(res.is_empty()); + + // Add messages to chat with Bob. + let mut msg1 = Message::new_text("foobar".to_string()); + send_msg(&alice, chat.id, &mut msg1).await?; + + let mut msg2 = Message::new_text("barbaz".to_string()); + send_msg(&alice, chat.id, &mut msg2).await?; + + alice.send_text(chat.id, "Δ-Chat").await; + + // Global search with a part of text finds the message. + let res = alice.search_msgs(None, "ob").await?; + assert_eq!(res.len(), 1); + + // Global search for "bar" matches both "foobar" and "barbaz". + let res = alice.search_msgs(None, "bar").await?; + assert_eq!(res.len(), 2); + + // Message added later is returned first. + assert_eq!(res.first(), Some(&msg2.id)); + assert_eq!(res.get(1), Some(&msg1.id)); + + // Search is case-insensitive. + for chat_id in [None, Some(chat.id)] { + let res = alice.search_msgs(chat_id, "δ-chat").await?; + assert_eq!(res.len(), 1); + } + + // Global search with longer text does not find any message. + let res = alice.search_msgs(None, "foobarbaz").await?; + assert!(res.is_empty()); + + // Search for random string finds nothing. + let res = alice.search_msgs(None, "abc").await?; + assert!(res.is_empty()); + + // Search in chat with Bob finds the message. + let res = alice.search_msgs(Some(chat.id), "foo").await?; + assert_eq!(res.len(), 1); + + // Search in Saved Messages does not find the message. + let res = alice.search_msgs(Some(self_talk), "foo").await?; + assert!(res.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_search_unaccepted_requests() -> Result<()> { + let t = TestContext::new_alice().await; + receive_imf( + &t, + b"From: BobBar \n\ + To: alice@example.org\n\ + Subject: foo\n\ + Message-ID: \n\ + Chat-Version: 1.0\n\ + Date: Tue, 25 Oct 2022 13:37:00 +0000\n\ + \n\ + hello bob, foobar test!\n", + false, + ) + .await?; + let chat_id = t.get_last_msg().await.get_chat_id(); + let chat = Chat::load_from_db(&t, chat_id).await?; + assert_eq!(chat.get_type(), Chattype::Single); + assert!(chat.is_contact_request()); + + assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1); + assert_eq!( + Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), + 1 + ); + assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1); + assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1); + + chat_id.block(&t).await?; + + assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 0); + assert_eq!( + Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), + 0 + ); + assert_eq!(t.search_msgs(None, "foobar").await?.len(), 0); + assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 0); + + let contact_ids = get_chat_contacts(&t, chat_id).await?; + Contact::unblock(&t, *contact_ids.first().unwrap()).await?; + + assert_eq!(Chatlist::try_load(&t, 0, None, None).await?.len(), 1); + assert_eq!( + Chatlist::try_load(&t, 0, Some("BobBar"), None).await?.len(), + 1 + ); + assert_eq!(t.search_msgs(None, "foobar").await?.len(), 1); + assert_eq!(t.search_msgs(Some(chat_id), "foobar").await?.len(), 1); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_limit_search_msgs() -> Result<()> { + let alice = TestContext::new_alice().await; + let chat = alice + .create_chat_with_contact("Bob", "bob@example.org") + .await; + + // Add 999 messages + let mut msg = Message::new_text("foobar".to_string()); + for _ in 0..999 { + send_msg(&alice, chat.id, &mut msg).await?; + } + let res = alice.search_msgs(None, "foo").await?; + assert_eq!(res.len(), 999); + + // Add one more message, no limit yet + send_msg(&alice, chat.id, &mut msg).await?; + let res = alice.search_msgs(None, "foo").await?; + assert_eq!(res.len(), 1000); + + // Add one more message, that one is truncated then + send_msg(&alice, chat.id, &mut msg).await?; + let res = alice.search_msgs(None, "foo").await?; + assert_eq!(res.len(), 1000); + + // In-chat should not be not limited + let res = alice.search_msgs(Some(chat.id), "foo").await?; + assert_eq!(res.len(), 1001); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_check_passphrase() -> Result<()> { + let dir = tempdir()?; + let dbfile = dir.path().join("db.sqlite"); + + let context = ContextBuilder::new(dbfile.clone()) + .with_id(1) + .build() + .await + .context("failed to create context")?; + assert_eq!(context.open("foo".to_string()).await?, true); + assert_eq!(context.is_open().await, true); + drop(context); + + let context = ContextBuilder::new(dbfile) + .with_id(2) + .build() + .await + .context("failed to create context")?; + assert_eq!(context.is_open().await, false); + assert_eq!(context.check_passphrase("bar".to_string()).await?, false); + assert_eq!(context.open("false".to_string()).await?, false); + assert_eq!(context.open("foo".to_string()).await?, true); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_context_change_passphrase() -> Result<()> { + let dir = tempdir()?; + let dbfile = dir.path().join("db.sqlite"); + + let context = ContextBuilder::new(dbfile) + .with_id(1) + .build() + .await + .context("failed to create context")?; + assert_eq!(context.open("foo".to_string()).await?, true); + assert_eq!(context.is_open().await, true); + + context + .set_config(Config::Addr, Some("alice@example.org")) + .await?; + + context + .change_passphrase("bar".to_string()) + .await + .context("Failed to change passphrase")?; + + assert_eq!( + context.get_config(Config::Addr).await?.unwrap(), + "alice@example.org" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ongoing() -> Result<()> { + let context = TestContext::new().await; + + // No ongoing process allocated. + assert!(context.shall_stop_ongoing().await); + + let receiver = context.alloc_ongoing().await?; + + // Cannot allocate another ongoing process while the first one is running. + assert!(context.alloc_ongoing().await.is_err()); + + // Stop signal is not sent yet. + assert!(receiver.try_recv().is_err()); + + assert!(!context.shall_stop_ongoing().await); + + // Send the stop signal. + context.stop_ongoing().await; + + // Receive stop signal. + receiver.recv().await?; + + assert!(context.shall_stop_ongoing().await); + + // Ongoing process is still running even though stop signal was received, + // so another one cannot be allocated. + assert!(context.alloc_ongoing().await.is_err()); + + context.free_ongoing().await; + + // No ongoing process allocated, should have been stopped already. + assert!(context.shall_stop_ongoing().await); + + // Another ongoing process can be allocated now. + let _receiver = context.alloc_ongoing().await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_next_msgs() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let alice_chat = alice.create_chat(&bob).await; + + assert!(alice.get_next_msgs().await?.is_empty()); + assert!(bob.get_next_msgs().await?.is_empty()); + + let sent_msg = alice.send_text(alice_chat.id, "Hi Bob").await; + let received_msg = bob.recv_msg(&sent_msg).await; + + let bob_next_msg_ids = bob.get_next_msgs().await?; + assert_eq!(bob_next_msg_ids.len(), 1); + assert_eq!(bob_next_msg_ids.first(), Some(&received_msg.id)); + + bob.set_config_u32(Config::LastMsgId, received_msg.id.to_u32()) + .await?; + assert!(bob.get_next_msgs().await?.is_empty()); + + // Next messages include self-sent messages. + let alice_next_msg_ids = alice.get_next_msgs().await?; + assert_eq!(alice_next_msg_ids.len(), 1); + assert_eq!(alice_next_msg_ids.first(), Some(&sent_msg.sender_msg_id)); + + alice + .set_config_u32(Config::LastMsgId, sent_msg.sender_msg_id.to_u32()) + .await?; + assert!(alice.get_next_msgs().await?.is_empty()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_draft_self_report() -> Result<()> { + let alice = TestContext::new_alice().await; + + let chat_id = alice.draft_self_report().await?; + let msg = get_chat_msg(&alice, chat_id, 0, 1).await; + assert_eq!(msg.get_info_type(), SystemMessage::ChatProtectionEnabled); + + let chat = Chat::load_from_db(&alice, chat_id).await?; + assert!(chat.is_protected()); + + let mut draft = chat_id.get_draft(&alice).await?.unwrap(); + assert!(draft.text.starts_with("core_version")); + + // Test that sending into the protected chat works: + let _sent = alice.send_msg(chat_id, &mut draft).await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_cache_is_cleared_when_io_is_started() -> Result<()> { + let alice = TestContext::new_alice().await; + assert_eq!( + alice.get_config(Config::ShowEmails).await?, + Some("2".to_string()) + ); + + // Change the config circumventing the cache + // This simulates what the notification plugin on iOS might do + // because it runs in a different process + alice + .sql + .execute( + "INSERT OR REPLACE INTO config (keyname, value) VALUES ('show_emails', '0')", + (), + ) + .await?; + + // Alice's Delta Chat doesn't know about it yet: + assert_eq!( + alice.get_config(Config::ShowEmails).await?, + Some("2".to_string()) + ); + + // Starting IO will fail of course because no server settings are configured, + // but it should invalidate the caches: + alice.start_io().await; + + assert_eq!( + alice.get_config(Config::ShowEmails).await?, + Some("0".to_string()) + ); + + Ok(()) +} diff --git a/src/ephemeral.rs b/src/ephemeral.rs index e0c00246a..29143089b 100644 --- a/src/ephemeral.rs +++ b/src/ephemeral.rs @@ -713,808 +713,4 @@ pub(crate) async fn start_ephemeral_timers(context: &Context) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use crate::chat::{marknoticed_chat, set_muted, ChatVisibility, MuteDuration}; - use crate::config::Config; - use crate::constants::DC_CHAT_ID_ARCHIVED_LINK; - use crate::download::DownloadState; - use crate::location; - use crate::message::markseen_msgs; - use crate::receive_imf::receive_imf; - use crate::test_utils::{TestContext, TestContextManager}; - use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE; - use crate::{ - chat::{self, create_group_chat, send_text_msg, Chat, ChatItem, ProtectionStatus}, - tools::IsNoneOrEmpty, - }; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_ephemeral_messages() { - let context = TestContext::new().await; - - assert_eq!( - stock_ephemeral_timer_changed(&context, Timer::Disabled, ContactId::SELF).await, - "You disabled message deletion timer." - ); - - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 1 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1 s." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 30 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 30 s." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 60 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1 minute." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 90 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1.5 minutes." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 30 * 60 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 30 minutes." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 60 * 60 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1 hour." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { duration: 5400 }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1.5 hours." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { - duration: 2 * 60 * 60 - }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 2 hours." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { - duration: 24 * 60 * 60 - }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1 day." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { - duration: 2 * 24 * 60 * 60 - }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 2 days." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { - duration: 7 * 24 * 60 * 60 - }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 1 week." - ); - assert_eq!( - stock_ephemeral_timer_changed( - &context, - Timer::Enabled { - duration: 4 * 7 * 24 * 60 * 60 - }, - ContactId::SELF - ) - .await, - "You set message deletion timer to 4 weeks." - ); - } - - /// Test enabling and disabling ephemeral timer remotely. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_enable_disable() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - - let chat_alice = alice.create_chat(&bob).await.id; - let chat_bob = bob.create_chat(&alice).await.id; - - chat_alice - .set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 }) - .await?; - let sent = alice.pop_sent_msg().await; - bob.recv_msg(&sent).await; - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Enabled { duration: 60 } - ); - - chat_alice - .set_ephemeral_timer(&alice.ctx, Timer::Disabled) - .await?; - let sent = alice.pop_sent_msg().await; - bob.recv_msg(&sent).await; - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Disabled - ); - - Ok(()) - } - - /// Test that enabling ephemeral timer in unpromoted group does not send a message. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_unpromoted() -> Result<()> { - let alice = TestContext::new_alice().await; - - let chat_id = - create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?; - - // Group is unpromoted, the timer can be changed without sending a message. - assert!(chat_id.is_unpromoted(&alice).await?); - chat_id - .set_ephemeral_timer(&alice, Timer::Enabled { duration: 60 }) - .await?; - let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; - assert!(sent.is_none()); - assert_eq!( - chat_id.get_ephemeral_timer(&alice).await?, - Timer::Enabled { duration: 60 } - ); - - // Promote the group. - send_text_msg(&alice, chat_id, "hi!".to_string()).await?; - assert!(chat_id.is_promoted(&alice).await?); - let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; - assert!(sent.is_some()); - - chat_id - .set_ephemeral_timer(&alice.ctx, Timer::Disabled) - .await?; - let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; - assert!(sent.is_some()); - assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled); - - Ok(()) - } - - /// Test that timer is enabled even if the message explicitly enabling the timer is lost. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_enable_lost() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - - let chat_alice = alice.create_chat(&bob).await.id; - let chat_bob = bob.create_chat(&alice).await.id; - - // Alice enables the timer. - chat_alice - .set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 }) - .await?; - assert_eq!( - chat_alice.get_ephemeral_timer(&alice.ctx).await?, - Timer::Enabled { duration: 60 } - ); - // The message enabling the timer is lost. - let _sent = alice.pop_sent_msg().await; - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Disabled, - ); - - // Alice sends a text message. - let mut msg = Message::new(Viewtype::Text); - chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; - let sent = alice.pop_sent_msg().await; - - // Bob receives text message and enables the timer, even though explicit timer update was - // lost previously. - bob.recv_msg(&sent).await; - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Enabled { duration: 60 } - ); - - Ok(()) - } - - /// Test that Alice replying to the chat without a timer at the same time as Bob enables the - /// timer does not result in disabling the timer on the Bob's side. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_timer_rollback() -> Result<()> { - let alice = TestContext::new_alice().await; - let bob = TestContext::new_bob().await; - - let chat_alice = alice.create_chat(&bob).await.id; - let chat_bob = bob.create_chat(&alice).await.id; - - // Alice sends message to Bob - let mut msg = Message::new(Viewtype::Text); - chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; - let sent = alice.pop_sent_msg().await; - bob.recv_msg(&sent).await; - - // Alice sends second message to Bob, with no timer - let mut msg = Message::new(Viewtype::Text); - chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; - let sent = alice.pop_sent_msg().await; - - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Disabled - ); - - // Bob sets ephemeral timer and sends a message about timer change - chat_bob - .set_ephemeral_timer(&bob.ctx, Timer::Enabled { duration: 60 }) - .await?; - let sent_timer_change = bob.pop_sent_msg().await; - - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Enabled { duration: 60 } - ); - - // Bob receives message from Alice. - // Alice message has no timer. However, Bob should not disable timer, - // because Alice replies to old message. - bob.recv_msg(&sent).await; - - assert_eq!( - chat_alice.get_ephemeral_timer(&alice.ctx).await?, - Timer::Disabled - ); - assert_eq!( - chat_bob.get_ephemeral_timer(&bob.ctx).await?, - Timer::Enabled { duration: 60 } - ); - - // Alice receives message from Bob - alice.recv_msg(&sent_timer_change).await; - - assert_eq!( - chat_alice.get_ephemeral_timer(&alice.ctx).await?, - Timer::Enabled { duration: 60 } - ); - - // Bob disables the chat timer. - // Note that the last message in the Bob's chat is from Alice and has no timer, - // but the chat timer is enabled. - chat_bob - .set_ephemeral_timer(&bob.ctx, Timer::Disabled) - .await?; - alice.recv_msg(&bob.pop_sent_msg().await).await; - assert_eq!( - chat_alice.get_ephemeral_timer(&alice.ctx).await?, - Timer::Disabled - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_delete_msgs() -> Result<()> { - let t = TestContext::new_alice().await; - let self_chat = t.get_self_chat().await; - - assert_eq!(next_expiration_timestamp(&t).await, None); - - t.send_text(self_chat.id, "Saved message, which we delete manually") - .await; - let msg = t.get_last_msg_in(self_chat.id).await; - msg.id.trash(&t, false).await?; - check_msg_is_deleted(&t, &self_chat, msg.id).await; - - self_chat - .id - .set_ephemeral_timer(&t, Timer::Enabled { duration: 3600 }) - .await - .unwrap(); - - // Send a saved message which will be deleted after 3600s - let now = time(); - let msg = t.send_text(self_chat.id, "Message text").await; - - check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601) - .await - .unwrap(); - - // Set DeleteDeviceAfter to 1800s. Then send a saved message which will - // still be deleted after 3600s because DeleteDeviceAfter doesn't apply to saved messages. - t.set_config(Config::DeleteDeviceAfter, Some("1800")) - .await?; - - let now = time(); - let msg = t.send_text(self_chat.id, "Message text").await; - - check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601) - .await - .unwrap(); - - // Send a message to Bob which will be deleted after 1800s because of DeleteDeviceAfter. - let bob_chat = t.create_chat_with_contact("", "bob@example.net").await; - let now = time(); - let msg = t.send_text(bob_chat.id, "Message text").await; - - check_msg_will_be_deleted( - &t, - msg.sender_msg_id, - &bob_chat, - now + 1799, - // The message may appear to be sent MAX_SECONDS_TO_LEND_FROM_FUTURE later and - // therefore be deleted MAX_SECONDS_TO_LEND_FROM_FUTURE later. - time() + 1801 + MAX_SECONDS_TO_LEND_FROM_FUTURE, - ) - .await - .unwrap(); - - // Enable ephemeral messages with Bob -> message will be deleted after 60s. - // This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time). - bob_chat - .id - .set_ephemeral_timer(&t, Timer::Enabled { duration: 60 }) - .await?; - - let now = time(); - let msg = t.send_text(bob_chat.id, "Message text").await; - - check_msg_will_be_deleted(&t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61) - .await - .unwrap(); - - Ok(()) - } - - async fn check_msg_will_be_deleted( - t: &TestContext, - msg_id: MsgId, - chat: &Chat, - not_deleted_at: i64, - deleted_at: i64, - ) -> Result<()> { - let next_expiration = next_expiration_timestamp(t).await.unwrap(); - - assert!(next_expiration > not_deleted_at); - delete_expired_messages(t, not_deleted_at).await?; - - let loaded = Message::load_from_db(t, msg_id).await?; - assert!(!loaded.text.is_empty()); - assert_eq!(loaded.chat_id, chat.id); - - assert!(next_expiration < deleted_at); - delete_expired_messages(t, deleted_at).await?; - t.evtracker - .get_matching(|evt| { - if let EventType::MsgDeleted { - msg_id: event_msg_id, - .. - } = evt - { - *event_msg_id == msg_id - } else { - false - } - }) - .await; - - let loaded = Message::load_from_db_optional(t, msg_id).await?; - assert!(loaded.is_none()); - - // Check that the msg was deleted locally. - check_msg_is_deleted(t, chat, msg_id).await; - - Ok(()) - } - - async fn check_msg_is_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) { - let chat_items = chat::get_chat_msgs(t, chat.id).await.unwrap(); - // Check that the chat is empty except for possibly info messages: - for item in &chat_items { - if let ChatItem::Message { msg_id } = item { - let msg = Message::load_from_db(t, *msg_id).await.unwrap(); - assert!(msg.is_info()) - } - } - - // Check that if there is a message left, the text and metadata are gone - if let Ok(msg) = Message::load_from_db(t, msg_id).await { - assert_eq!(msg.from_id, ContactId::UNDEFINED); - assert_eq!(msg.to_id, ContactId::UNDEFINED); - assert_eq!(msg.text, ""); - let rawtxt: Option = t - .sql - .query_get_value("SELECT txt_raw FROM msgs WHERE id=?;", (msg_id,)) - .await - .unwrap(); - assert!(rawtxt.is_none_or_empty(), "{rawtxt:?}"); - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_delete_expired_imap_messages() -> Result<()> { - let t = TestContext::new_alice().await; - const HOUR: i64 = 60 * 60; - let now = time(); - for (id, timestamp, ephemeral_timestamp) in &[ - (900, now - 2 * HOUR, 0), - (1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0), - (1010, now - 23 * HOUR, 0), - (1020, now - 21 * HOUR, 0), - (1030, now - 19 * HOUR, 0), - (2000, now - 18 * HOUR, now - HOUR), - (2020, now - 17 * HOUR, now + HOUR), - (3000, now + HOUR, 0), - ] { - let message_id = id.to_string(); - t.sql - .execute( - "INSERT INTO msgs (id, rfc724_mid, timestamp, ephemeral_timestamp) VALUES (?,?,?,?);", - (id, &message_id, timestamp, ephemeral_timestamp), - ) - .await?; - t.sql - .execute( - "INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');", - (&message_id, id), - ) - .await?; - } - - async fn test_marked_for_deletion(context: &Context, id: u32) -> Result<()> { - assert_eq!( - context - .sql - .count( - "SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?", - (id.to_string(),), - ) - .await?, - 1 - ); - Ok(()) - } - - async fn remove_uid(context: &Context, id: u32) -> Result<()> { - context - .sql - .execute("DELETE FROM imap WHERE rfc724_mid=?", (id.to_string(),)) - .await?; - Ok(()) - } - - // This should mark message 2000 for deletion. - delete_expired_imap_messages(&t).await?; - test_marked_for_deletion(&t, 2000).await?; - remove_uid(&t, 2000).await?; - // No other messages are marked for deletion. - assert_eq!( - t.sql - .count("SELECT COUNT(*) FROM imap WHERE target=''", ()) - .await?, - 0 - ); - - t.set_config(Config::DeleteServerAfter, Some(&*(25 * HOUR).to_string())) - .await?; - delete_expired_imap_messages(&t).await?; - test_marked_for_deletion(&t, 1000).await?; - - MsgId::new(1000) - .update_download_state(&t, DownloadState::Available) - .await?; - t.sql - .execute("UPDATE imap SET target=folder WHERE rfc724_mid='1000'", ()) - .await?; - delete_expired_imap_messages(&t).await?; - test_marked_for_deletion(&t, 1000).await?; // Delete downloadable anyway. - remove_uid(&t, 1000).await?; - - t.set_config(Config::DeleteServerAfter, Some(&*(22 * HOUR).to_string())) - .await?; - delete_expired_imap_messages(&t).await?; - test_marked_for_deletion(&t, 1010).await?; - t.sql - .execute("UPDATE imap SET target=folder WHERE rfc724_mid='1010'", ()) - .await?; - - MsgId::new(1010) - .update_download_state(&t, DownloadState::Available) - .await?; - delete_expired_imap_messages(&t).await?; - // Keep downloadable for now. - assert_eq!( - t.sql - .count("SELECT COUNT(*) FROM imap WHERE target=''", ()) - .await?, - 0 - ); - - t.set_config(Config::DeleteServerAfter, Some("1")).await?; - delete_expired_imap_messages(&t).await?; - test_marked_for_deletion(&t, 3000).await?; - - Ok(()) - } - - // Regression test for a bug in the timer rollback protection. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_timer_references() -> Result<()> { - let alice = TestContext::new_alice().await; - - // Message with Message-ID and no timer is received. - receive_imf( - &alice, - b"From: Bob \n\ - To: Alice \n\ - Chat-Version: 1.0\n\ - Subject: Subject\n\ - Message-ID: \n\ - Date: Sun, 22 Mar 2020 00:10:00 +0000\n\ - \n\ - hello\n", - false, - ) - .await?; - - let msg = alice.get_last_msg().await; - let chat_id = msg.chat_id; - assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled); - - // Message with Message-ID is received. - receive_imf( - &alice, - b"From: Bob \n\ - To: Alice \n\ - Chat-Version: 1.0\n\ - Subject: Subject\n\ - Message-ID: \n\ - Date: Sun, 22 Mar 2020 00:11:00 +0000\n\ - Ephemeral-Timer: 60\n\ - \n\ - second message\n", - false, - ) - .await?; - assert_eq!( - chat_id.get_ephemeral_timer(&alice).await?, - Timer::Enabled { duration: 60 } - ); - let msg = alice.get_last_msg().await; - - // Message is deleted when its timer expires. - msg.id.trash(&alice, false).await?; - - // Message with Message-ID , referencing and - // , is received. The message is not in the - // database anymore, so the timer should be applied unconditionally without rollback - // protection. - // - // Previously Delta Chat fallen back to using in this case and - // compared received timer value to the timer value of the . Because - // their timer values are the same ("disabled"), Delta Chat assumed that the timer was not - // changed explicitly and the change should be ignored. - // - // The message also contains a quote of the first message to test that only References: - // header and not In-Reply-To: is consulted by the rollback protection. - receive_imf( - &alice, - b"From: Bob \n\ - To: Alice \n\ - Chat-Version: 1.0\n\ - Subject: Subject\n\ - Message-ID: \n\ - Date: Sun, 22 Mar 2020 00:12:00 +0000\n\ - References: \n\ - In-Reply-To: \n\ - \n\ - > hello\n", - false, - ) - .await?; - - let msg = alice.get_last_msg().await; - assert_eq!( - msg.chat_id.get_ephemeral_timer(&alice).await?, - Timer::Disabled - ); - - Ok(()) - } - - // Tests that if we are offline for a time longer than the ephemeral timer duration, the message - // is deleted from the chat but is still in the "smtp" table, i.e. will be sent upon a - // successful reconnection. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_msg_offline() -> Result<()> { - let alice = TestContext::new_alice().await; - let chat = alice - .create_chat_with_contact("Bob", "bob@example.org") - .await; - let duration = 60; - chat.id - .set_ephemeral_timer(&alice, Timer::Enabled { duration }) - .await?; - let mut msg = Message::new_text("hi".to_string()); - assert!(chat::send_msg_sync(&alice, chat.id, &mut msg) - .await - .is_err()); - let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?"; - assert!(alice.sql.exists(stmt, (msg.id,)).await?); - let now = time(); - check_msg_will_be_deleted(&alice, msg.id, &chat, now, now + i64::from(duration) + 1) - .await?; - assert!(alice.sql.exists(stmt, (msg.id,)).await?); - - Ok(()) - } - - /// Tests that POI location is deleted when ephemeral message expires. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_ephemeral_poi_location() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - let chat = alice.create_chat(bob).await; - - let duration = 60; - chat.id - .set_ephemeral_timer(alice, Timer::Enabled { duration }) - .await?; - let sent = alice.pop_sent_msg().await; - bob.recv_msg(&sent).await; - - let mut poi_msg = Message::new_text("Here".to_string()); - poi_msg.set_location(10.0, 20.0); - - let alice_sent_message = alice.send_msg(chat.id, &mut poi_msg).await; - let bob_received_message = bob.recv_msg(&alice_sent_message).await; - markseen_msgs(bob, vec![bob_received_message.id]).await?; - - for account in [alice, bob] { - let locations = location::get_range(account, None, None, 0, 0).await?; - assert_eq!(locations.len(), 1); - } - - SystemTime::shift(Duration::from_secs(100)); - - for account in [alice, bob] { - delete_expired_messages(account, time()).await?; - let locations = location::get_range(account, None, None, 0, 0).await?; - assert_eq!(locations.len(), 0); - } - - Ok(()) - } - - /// Tests that `.get_ephemeral_timer()` returns an error for invalid chat ID. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_ephemeral_timer_wrong_chat_id() -> Result<()> { - let context = TestContext::new().await; - let chat_id = ChatId::new(12345); - assert!(chat_id.get_ephemeral_timer(&context).await.is_err()); - - Ok(()) - } - - /// Tests that ephemeral timer is started when the chat is noticed. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_noticed_ephemeral_timer() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - let chat = alice.create_chat(bob).await; - let duration = 60; - chat.id - .set_ephemeral_timer(alice, Timer::Enabled { duration }) - .await?; - let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await; - - marknoticed_chat(bob, bob_received_message.chat_id).await?; - SystemTime::shift(Duration::from_secs(100)); - - delete_expired_messages(bob, time()).await?; - - assert!(Message::load_from_db_optional(bob, bob_received_message.id) - .await? - .is_none()); - Ok(()) - } - - /// Tests that archiving the chat starts ephemeral timer. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_archived_ephemeral_timer() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - - let chat = alice.create_chat(bob).await; - let duration = 60; - chat.id - .set_ephemeral_timer(alice, Timer::Enabled { duration }) - .await?; - let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await; - - bob_received_message - .chat_id - .set_visibility(bob, ChatVisibility::Archived) - .await?; - SystemTime::shift(Duration::from_secs(100)); - - delete_expired_messages(bob, time()).await?; - - assert!(Message::load_from_db_optional(bob, bob_received_message.id) - .await? - .is_none()); - - // Bob mutes the chat so it is not unarchived. - set_muted(bob, bob_received_message.chat_id, MuteDuration::Forever).await?; - - // Now test that for already archived chat - // timer is started if all archived chats are marked as noticed. - let bob_received_message_2 = tcm.send_recv(alice, bob, "Hello again!").await; - assert_eq!(bob_received_message_2.state, MessageState::InFresh); - - marknoticed_chat(bob, DC_CHAT_ID_ARCHIVED_LINK).await?; - SystemTime::shift(Duration::from_secs(100)); - - delete_expired_messages(bob, time()).await?; - - assert!( - Message::load_from_db_optional(bob, bob_received_message_2.id) - .await? - .is_none() - ); - - Ok(()) - } -} +mod ephemeral_tests; diff --git a/src/ephemeral/ephemeral_tests.rs b/src/ephemeral/ephemeral_tests.rs new file mode 100644 index 000000000..63c29ecf3 --- /dev/null +++ b/src/ephemeral/ephemeral_tests.rs @@ -0,0 +1,781 @@ +use super::*; +use crate::chat::{marknoticed_chat, set_muted, ChatVisibility, MuteDuration}; +use crate::config::Config; +use crate::constants::DC_CHAT_ID_ARCHIVED_LINK; +use crate::download::DownloadState; +use crate::location; +use crate::message::markseen_msgs; +use crate::receive_imf::receive_imf; +use crate::test_utils::{TestContext, TestContextManager}; +use crate::timesmearing::MAX_SECONDS_TO_LEND_FROM_FUTURE; +use crate::{ + chat::{self, create_group_chat, send_text_msg, Chat, ChatItem, ProtectionStatus}, + tools::IsNoneOrEmpty, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_ephemeral_messages() { + let context = TestContext::new().await; + + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Disabled, ContactId::SELF).await, + "You disabled message deletion timer." + ); + + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 1 }, ContactId::SELF) + .await, + "You set message deletion timer to 1 s." + ); + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 30 }, ContactId::SELF) + .await, + "You set message deletion timer to 30 s." + ); + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 60 }, ContactId::SELF) + .await, + "You set message deletion timer to 1 minute." + ); + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 90 }, ContactId::SELF) + .await, + "You set message deletion timer to 1.5 minutes." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { duration: 30 * 60 }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 30 minutes." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { duration: 60 * 60 }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 1 hour." + ); + assert_eq!( + stock_ephemeral_timer_changed(&context, Timer::Enabled { duration: 5400 }, ContactId::SELF) + .await, + "You set message deletion timer to 1.5 hours." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { + duration: 2 * 60 * 60 + }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 2 hours." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { + duration: 24 * 60 * 60 + }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 1 day." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { + duration: 2 * 24 * 60 * 60 + }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 2 days." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { + duration: 7 * 24 * 60 * 60 + }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 1 week." + ); + assert_eq!( + stock_ephemeral_timer_changed( + &context, + Timer::Enabled { + duration: 4 * 7 * 24 * 60 * 60 + }, + ContactId::SELF + ) + .await, + "You set message deletion timer to 4 weeks." + ); +} + +/// Test enabling and disabling ephemeral timer remotely. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_enable_disable() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await.id; + let chat_bob = bob.create_chat(&alice).await.id; + + chat_alice + .set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 }) + .await?; + let sent = alice.pop_sent_msg().await; + bob.recv_msg(&sent).await; + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Enabled { duration: 60 } + ); + + chat_alice + .set_ephemeral_timer(&alice.ctx, Timer::Disabled) + .await?; + let sent = alice.pop_sent_msg().await; + bob.recv_msg(&sent).await; + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Disabled + ); + + Ok(()) +} + +/// Test that enabling ephemeral timer in unpromoted group does not send a message. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_unpromoted() -> Result<()> { + let alice = TestContext::new_alice().await; + + let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "Group name").await?; + + // Group is unpromoted, the timer can be changed without sending a message. + assert!(chat_id.is_unpromoted(&alice).await?); + chat_id + .set_ephemeral_timer(&alice, Timer::Enabled { duration: 60 }) + .await?; + let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; + assert!(sent.is_none()); + assert_eq!( + chat_id.get_ephemeral_timer(&alice).await?, + Timer::Enabled { duration: 60 } + ); + + // Promote the group. + send_text_msg(&alice, chat_id, "hi!".to_string()).await?; + assert!(chat_id.is_promoted(&alice).await?); + let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; + assert!(sent.is_some()); + + chat_id + .set_ephemeral_timer(&alice.ctx, Timer::Disabled) + .await?; + let sent = alice.pop_sent_msg_opt(Duration::from_secs(1)).await; + assert!(sent.is_some()); + assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled); + + Ok(()) +} + +/// Test that timer is enabled even if the message explicitly enabling the timer is lost. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_enable_lost() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await.id; + let chat_bob = bob.create_chat(&alice).await.id; + + // Alice enables the timer. + chat_alice + .set_ephemeral_timer(&alice.ctx, Timer::Enabled { duration: 60 }) + .await?; + assert_eq!( + chat_alice.get_ephemeral_timer(&alice.ctx).await?, + Timer::Enabled { duration: 60 } + ); + // The message enabling the timer is lost. + let _sent = alice.pop_sent_msg().await; + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Disabled, + ); + + // Alice sends a text message. + let mut msg = Message::new(Viewtype::Text); + chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; + let sent = alice.pop_sent_msg().await; + + // Bob receives text message and enables the timer, even though explicit timer update was + // lost previously. + bob.recv_msg(&sent).await; + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Enabled { duration: 60 } + ); + + Ok(()) +} + +/// Test that Alice replying to the chat without a timer at the same time as Bob enables the +/// timer does not result in disabling the timer on the Bob's side. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_timer_rollback() -> Result<()> { + let alice = TestContext::new_alice().await; + let bob = TestContext::new_bob().await; + + let chat_alice = alice.create_chat(&bob).await.id; + let chat_bob = bob.create_chat(&alice).await.id; + + // Alice sends message to Bob + let mut msg = Message::new(Viewtype::Text); + chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; + let sent = alice.pop_sent_msg().await; + bob.recv_msg(&sent).await; + + // Alice sends second message to Bob, with no timer + let mut msg = Message::new(Viewtype::Text); + chat::send_msg(&alice.ctx, chat_alice, &mut msg).await?; + let sent = alice.pop_sent_msg().await; + + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Disabled + ); + + // Bob sets ephemeral timer and sends a message about timer change + chat_bob + .set_ephemeral_timer(&bob.ctx, Timer::Enabled { duration: 60 }) + .await?; + let sent_timer_change = bob.pop_sent_msg().await; + + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Enabled { duration: 60 } + ); + + // Bob receives message from Alice. + // Alice message has no timer. However, Bob should not disable timer, + // because Alice replies to old message. + bob.recv_msg(&sent).await; + + assert_eq!( + chat_alice.get_ephemeral_timer(&alice.ctx).await?, + Timer::Disabled + ); + assert_eq!( + chat_bob.get_ephemeral_timer(&bob.ctx).await?, + Timer::Enabled { duration: 60 } + ); + + // Alice receives message from Bob + alice.recv_msg(&sent_timer_change).await; + + assert_eq!( + chat_alice.get_ephemeral_timer(&alice.ctx).await?, + Timer::Enabled { duration: 60 } + ); + + // Bob disables the chat timer. + // Note that the last message in the Bob's chat is from Alice and has no timer, + // but the chat timer is enabled. + chat_bob + .set_ephemeral_timer(&bob.ctx, Timer::Disabled) + .await?; + alice.recv_msg(&bob.pop_sent_msg().await).await; + assert_eq!( + chat_alice.get_ephemeral_timer(&alice.ctx).await?, + Timer::Disabled + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_delete_msgs() -> Result<()> { + let t = TestContext::new_alice().await; + let self_chat = t.get_self_chat().await; + + assert_eq!(next_expiration_timestamp(&t).await, None); + + t.send_text(self_chat.id, "Saved message, which we delete manually") + .await; + let msg = t.get_last_msg_in(self_chat.id).await; + msg.id.trash(&t, false).await?; + check_msg_is_deleted(&t, &self_chat, msg.id).await; + + self_chat + .id + .set_ephemeral_timer(&t, Timer::Enabled { duration: 3600 }) + .await + .unwrap(); + + // Send a saved message which will be deleted after 3600s + let now = time(); + let msg = t.send_text(self_chat.id, "Message text").await; + + check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3599, time() + 3601) + .await + .unwrap(); + + // Set DeleteDeviceAfter to 1800s. Then send a saved message which will + // still be deleted after 3600s because DeleteDeviceAfter doesn't apply to saved messages. + t.set_config(Config::DeleteDeviceAfter, Some("1800")) + .await?; + + let now = time(); + let msg = t.send_text(self_chat.id, "Message text").await; + + check_msg_will_be_deleted(&t, msg.sender_msg_id, &self_chat, now + 3559, time() + 3601) + .await + .unwrap(); + + // Send a message to Bob which will be deleted after 1800s because of DeleteDeviceAfter. + let bob_chat = t.create_chat_with_contact("", "bob@example.net").await; + let now = time(); + let msg = t.send_text(bob_chat.id, "Message text").await; + + check_msg_will_be_deleted( + &t, + msg.sender_msg_id, + &bob_chat, + now + 1799, + // The message may appear to be sent MAX_SECONDS_TO_LEND_FROM_FUTURE later and + // therefore be deleted MAX_SECONDS_TO_LEND_FROM_FUTURE later. + time() + 1801 + MAX_SECONDS_TO_LEND_FROM_FUTURE, + ) + .await + .unwrap(); + + // Enable ephemeral messages with Bob -> message will be deleted after 60s. + // This tests that the message is deleted at min(ephemeral deletion time, DeleteDeviceAfter deletion time). + bob_chat + .id + .set_ephemeral_timer(&t, Timer::Enabled { duration: 60 }) + .await?; + + let now = time(); + let msg = t.send_text(bob_chat.id, "Message text").await; + + check_msg_will_be_deleted(&t, msg.sender_msg_id, &bob_chat, now + 59, time() + 61) + .await + .unwrap(); + + Ok(()) +} + +async fn check_msg_will_be_deleted( + t: &TestContext, + msg_id: MsgId, + chat: &Chat, + not_deleted_at: i64, + deleted_at: i64, +) -> Result<()> { + let next_expiration = next_expiration_timestamp(t).await.unwrap(); + + assert!(next_expiration > not_deleted_at); + delete_expired_messages(t, not_deleted_at).await?; + + let loaded = Message::load_from_db(t, msg_id).await?; + assert!(!loaded.text.is_empty()); + assert_eq!(loaded.chat_id, chat.id); + + assert!(next_expiration < deleted_at); + delete_expired_messages(t, deleted_at).await?; + t.evtracker + .get_matching(|evt| { + if let EventType::MsgDeleted { + msg_id: event_msg_id, + .. + } = evt + { + *event_msg_id == msg_id + } else { + false + } + }) + .await; + + let loaded = Message::load_from_db_optional(t, msg_id).await?; + assert!(loaded.is_none()); + + // Check that the msg was deleted locally. + check_msg_is_deleted(t, chat, msg_id).await; + + Ok(()) +} + +async fn check_msg_is_deleted(t: &TestContext, chat: &Chat, msg_id: MsgId) { + let chat_items = chat::get_chat_msgs(t, chat.id).await.unwrap(); + // Check that the chat is empty except for possibly info messages: + for item in &chat_items { + if let ChatItem::Message { msg_id } = item { + let msg = Message::load_from_db(t, *msg_id).await.unwrap(); + assert!(msg.is_info()) + } + } + + // Check that if there is a message left, the text and metadata are gone + if let Ok(msg) = Message::load_from_db(t, msg_id).await { + assert_eq!(msg.from_id, ContactId::UNDEFINED); + assert_eq!(msg.to_id, ContactId::UNDEFINED); + assert_eq!(msg.text, ""); + let rawtxt: Option = t + .sql + .query_get_value("SELECT txt_raw FROM msgs WHERE id=?;", (msg_id,)) + .await + .unwrap(); + assert!(rawtxt.is_none_or_empty(), "{rawtxt:?}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_delete_expired_imap_messages() -> Result<()> { + let t = TestContext::new_alice().await; + const HOUR: i64 = 60 * 60; + let now = time(); + for (id, timestamp, ephemeral_timestamp) in &[ + (900, now - 2 * HOUR, 0), + (1000, now - 23 * HOUR - MIN_DELETE_SERVER_AFTER, 0), + (1010, now - 23 * HOUR, 0), + (1020, now - 21 * HOUR, 0), + (1030, now - 19 * HOUR, 0), + (2000, now - 18 * HOUR, now - HOUR), + (2020, now - 17 * HOUR, now + HOUR), + (3000, now + HOUR, 0), + ] { + let message_id = id.to_string(); + t.sql + .execute( + "INSERT INTO msgs (id, rfc724_mid, timestamp, ephemeral_timestamp) VALUES (?,?,?,?);", + (id, &message_id, timestamp, ephemeral_timestamp), + ) + .await?; + t.sql + .execute( + "INSERT INTO imap (rfc724_mid, folder, uid, target) VALUES (?,'INBOX',?, 'INBOX');", + (&message_id, id), + ) + .await?; + } + + async fn test_marked_for_deletion(context: &Context, id: u32) -> Result<()> { + assert_eq!( + context + .sql + .count( + "SELECT COUNT(*) FROM imap WHERE target='' AND rfc724_mid=?", + (id.to_string(),), + ) + .await?, + 1 + ); + Ok(()) + } + + async fn remove_uid(context: &Context, id: u32) -> Result<()> { + context + .sql + .execute("DELETE FROM imap WHERE rfc724_mid=?", (id.to_string(),)) + .await?; + Ok(()) + } + + // This should mark message 2000 for deletion. + delete_expired_imap_messages(&t).await?; + test_marked_for_deletion(&t, 2000).await?; + remove_uid(&t, 2000).await?; + // No other messages are marked for deletion. + assert_eq!( + t.sql + .count("SELECT COUNT(*) FROM imap WHERE target=''", ()) + .await?, + 0 + ); + + t.set_config(Config::DeleteServerAfter, Some(&*(25 * HOUR).to_string())) + .await?; + delete_expired_imap_messages(&t).await?; + test_marked_for_deletion(&t, 1000).await?; + + MsgId::new(1000) + .update_download_state(&t, DownloadState::Available) + .await?; + t.sql + .execute("UPDATE imap SET target=folder WHERE rfc724_mid='1000'", ()) + .await?; + delete_expired_imap_messages(&t).await?; + test_marked_for_deletion(&t, 1000).await?; // Delete downloadable anyway. + remove_uid(&t, 1000).await?; + + t.set_config(Config::DeleteServerAfter, Some(&*(22 * HOUR).to_string())) + .await?; + delete_expired_imap_messages(&t).await?; + test_marked_for_deletion(&t, 1010).await?; + t.sql + .execute("UPDATE imap SET target=folder WHERE rfc724_mid='1010'", ()) + .await?; + + MsgId::new(1010) + .update_download_state(&t, DownloadState::Available) + .await?; + delete_expired_imap_messages(&t).await?; + // Keep downloadable for now. + assert_eq!( + t.sql + .count("SELECT COUNT(*) FROM imap WHERE target=''", ()) + .await?, + 0 + ); + + t.set_config(Config::DeleteServerAfter, Some("1")).await?; + delete_expired_imap_messages(&t).await?; + test_marked_for_deletion(&t, 3000).await?; + + Ok(()) +} + +// Regression test for a bug in the timer rollback protection. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_timer_references() -> Result<()> { + let alice = TestContext::new_alice().await; + + // Message with Message-ID and no timer is received. + receive_imf( + &alice, + b"From: Bob \n\ + To: Alice \n\ + Chat-Version: 1.0\n\ + Subject: Subject\n\ + Message-ID: \n\ + Date: Sun, 22 Mar 2020 00:10:00 +0000\n\ + \n\ + hello\n", + false, + ) + .await?; + + let msg = alice.get_last_msg().await; + let chat_id = msg.chat_id; + assert_eq!(chat_id.get_ephemeral_timer(&alice).await?, Timer::Disabled); + + // Message with Message-ID is received. + receive_imf( + &alice, + b"From: Bob \n\ + To: Alice \n\ + Chat-Version: 1.0\n\ + Subject: Subject\n\ + Message-ID: \n\ + Date: Sun, 22 Mar 2020 00:11:00 +0000\n\ + Ephemeral-Timer: 60\n\ + \n\ + second message\n", + false, + ) + .await?; + assert_eq!( + chat_id.get_ephemeral_timer(&alice).await?, + Timer::Enabled { duration: 60 } + ); + let msg = alice.get_last_msg().await; + + // Message is deleted when its timer expires. + msg.id.trash(&alice, false).await?; + + // Message with Message-ID , referencing and + // , is received. The message is not in the + // database anymore, so the timer should be applied unconditionally without rollback + // protection. + // + // Previously Delta Chat fallen back to using in this case and + // compared received timer value to the timer value of the . Because + // their timer values are the same ("disabled"), Delta Chat assumed that the timer was not + // changed explicitly and the change should be ignored. + // + // The message also contains a quote of the first message to test that only References: + // header and not In-Reply-To: is consulted by the rollback protection. + receive_imf( + &alice, + b"From: Bob \n\ + To: Alice \n\ + Chat-Version: 1.0\n\ + Subject: Subject\n\ + Message-ID: \n\ + Date: Sun, 22 Mar 2020 00:12:00 +0000\n\ + References: \n\ + In-Reply-To: \n\ + \n\ + > hello\n", + false, + ) + .await?; + + let msg = alice.get_last_msg().await; + assert_eq!( + msg.chat_id.get_ephemeral_timer(&alice).await?, + Timer::Disabled + ); + + Ok(()) +} + +// Tests that if we are offline for a time longer than the ephemeral timer duration, the message +// is deleted from the chat but is still in the "smtp" table, i.e. will be sent upon a +// successful reconnection. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_msg_offline() -> Result<()> { + let alice = TestContext::new_alice().await; + let chat = alice + .create_chat_with_contact("Bob", "bob@example.org") + .await; + let duration = 60; + chat.id + .set_ephemeral_timer(&alice, Timer::Enabled { duration }) + .await?; + let mut msg = Message::new_text("hi".to_string()); + assert!(chat::send_msg_sync(&alice, chat.id, &mut msg) + .await + .is_err()); + let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?"; + assert!(alice.sql.exists(stmt, (msg.id,)).await?); + let now = time(); + check_msg_will_be_deleted(&alice, msg.id, &chat, now, now + i64::from(duration) + 1).await?; + assert!(alice.sql.exists(stmt, (msg.id,)).await?); + + Ok(()) +} + +/// Tests that POI location is deleted when ephemeral message expires. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_ephemeral_poi_location() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat = alice.create_chat(bob).await; + + let duration = 60; + chat.id + .set_ephemeral_timer(alice, Timer::Enabled { duration }) + .await?; + let sent = alice.pop_sent_msg().await; + bob.recv_msg(&sent).await; + + let mut poi_msg = Message::new_text("Here".to_string()); + poi_msg.set_location(10.0, 20.0); + + let alice_sent_message = alice.send_msg(chat.id, &mut poi_msg).await; + let bob_received_message = bob.recv_msg(&alice_sent_message).await; + markseen_msgs(bob, vec![bob_received_message.id]).await?; + + for account in [alice, bob] { + let locations = location::get_range(account, None, None, 0, 0).await?; + assert_eq!(locations.len(), 1); + } + + SystemTime::shift(Duration::from_secs(100)); + + for account in [alice, bob] { + delete_expired_messages(account, time()).await?; + let locations = location::get_range(account, None, None, 0, 0).await?; + assert_eq!(locations.len(), 0); + } + + Ok(()) +} + +/// Tests that `.get_ephemeral_timer()` returns an error for invalid chat ID. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_ephemeral_timer_wrong_chat_id() -> Result<()> { + let context = TestContext::new().await; + let chat_id = ChatId::new(12345); + assert!(chat_id.get_ephemeral_timer(&context).await.is_err()); + + Ok(()) +} + +/// Tests that ephemeral timer is started when the chat is noticed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_noticed_ephemeral_timer() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat = alice.create_chat(bob).await; + let duration = 60; + chat.id + .set_ephemeral_timer(alice, Timer::Enabled { duration }) + .await?; + let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await; + + marknoticed_chat(bob, bob_received_message.chat_id).await?; + SystemTime::shift(Duration::from_secs(100)); + + delete_expired_messages(bob, time()).await?; + + assert!(Message::load_from_db_optional(bob, bob_received_message.id) + .await? + .is_none()); + Ok(()) +} + +/// Tests that archiving the chat starts ephemeral timer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_archived_ephemeral_timer() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + + let chat = alice.create_chat(bob).await; + let duration = 60; + chat.id + .set_ephemeral_timer(alice, Timer::Enabled { duration }) + .await?; + let bob_received_message = tcm.send_recv(alice, bob, "Hello!").await; + + bob_received_message + .chat_id + .set_visibility(bob, ChatVisibility::Archived) + .await?; + SystemTime::shift(Duration::from_secs(100)); + + delete_expired_messages(bob, time()).await?; + + assert!(Message::load_from_db_optional(bob, bob_received_message.id) + .await? + .is_none()); + + // Bob mutes the chat so it is not unarchived. + set_muted(bob, bob_received_message.chat_id, MuteDuration::Forever).await?; + + // Now test that for already archived chat + // timer is started if all archived chats are marked as noticed. + let bob_received_message_2 = tcm.send_recv(alice, bob, "Hello again!").await; + assert_eq!(bob_received_message_2.state, MessageState::InFresh); + + marknoticed_chat(bob, DC_CHAT_ID_ARCHIVED_LINK).await?; + SystemTime::shift(Duration::from_secs(100)); + + delete_expired_messages(bob, time()).await?; + + assert!( + Message::load_from_db_optional(bob, bob_received_message_2.id) + .await? + .is_none() + ); + + Ok(()) +} diff --git a/src/imap.rs b/src/imap.rs index dc1b531b4..fd6ef61b2 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -2641,342 +2641,4 @@ async fn add_all_recipients_as_contacts( } #[cfg(test)] -mod tests { - use super::*; - use crate::test_utils::TestContext; - - #[test] - fn test_get_folder_meaning_by_name() { - assert_eq!(get_folder_meaning_by_name("Gesendet"), FolderMeaning::Sent); - assert_eq!(get_folder_meaning_by_name("GESENDET"), FolderMeaning::Sent); - assert_eq!(get_folder_meaning_by_name("gesendet"), FolderMeaning::Sent); - assert_eq!( - get_folder_meaning_by_name("Messages envoyés"), - FolderMeaning::Sent - ); - assert_eq!( - get_folder_meaning_by_name("mEsSaGes envoyÉs"), - FolderMeaning::Sent - ); - assert_eq!(get_folder_meaning_by_name("xxx"), FolderMeaning::Unknown); - assert_eq!(get_folder_meaning_by_name("SPAM"), FolderMeaning::Spam); - assert_eq!(get_folder_meaning_by_name("Trash"), FolderMeaning::Trash); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_uid_next_validity() { - let t = TestContext::new_alice().await; - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0); - - set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap(); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7); - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); - - set_uid_next(&t.ctx, "Inbox", 5).await.unwrap(); - set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap(); - assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5); - assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6); - } - - #[test] - fn test_build_sequence_sets() { - assert_eq!(build_sequence_sets(&[]).unwrap(), vec![]); - - let cases = vec![ - (vec![1], "1"), - (vec![3291], "3291"), - (vec![1, 3, 5, 7, 9, 11], "1,3,5,7,9,11"), - (vec![1, 2, 3], "1:3"), - (vec![1, 4, 5, 6], "1,4:6"), - ((1..=500).collect(), "1:500"), - (vec![3, 4, 8, 9, 10, 11, 39, 50, 2], "3:4,8:11,39,50,2"), - ]; - for (input, s) in cases { - assert_eq!( - build_sequence_sets(&input).unwrap(), - vec![(input, s.into())] - ); - } - - let has_number = |(uids, s): &(Vec, String), number| { - uids.iter().any(|&n| n == number) - && s.split(',').any(|n| n.parse::().unwrap() == number) - }; - - let numbers: Vec<_> = (2..=500).step_by(2).collect(); - let result = build_sequence_sets(&numbers).unwrap(); - for (_, set) in &result { - assert!(set.len() < 1010); - assert!(!set.ends_with(',')); - assert!(!set.starts_with(',')); - } - assert!(result.len() == 1); // these UIDs fit in one set - for &number in &numbers { - assert!(result.iter().any(|r| has_number(r, number))); - } - - let numbers: Vec<_> = (1..=1000).step_by(3).collect(); - let result = build_sequence_sets(&numbers).unwrap(); - for (_, set) in &result { - assert!(set.len() < 1010); - assert!(!set.ends_with(',')); - assert!(!set.starts_with(',')); - } - let (last_uids, last_str) = result.last().unwrap(); - assert_eq!( - last_uids.get((last_uids.len() - 2)..).unwrap(), - &[997, 1000] - ); - assert!(last_str.ends_with("997,1000")); - assert!(result.len() == 2); // This time we need 2 sets - for &number in &numbers { - assert!(result.iter().any(|r| has_number(r, number))); - } - - let numbers: Vec<_> = (30000000..=30002500).step_by(4).collect(); - let result = build_sequence_sets(&numbers).unwrap(); - for (_, set) in &result { - assert!(set.len() < 1010); - assert!(!set.ends_with(',')); - assert!(!set.starts_with(',')); - } - assert_eq!(result.len(), 6); - for &number in &numbers { - assert!(result.iter().any(|r| has_number(r, number))); - } - } - - async fn check_target_folder_combination( - folder: &str, - mvbox_move: bool, - chat_msg: bool, - expected_destination: &str, - accepted_chat: bool, - outgoing: bool, - setupmessage: bool, - ) -> Result<()> { - println!("Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"); - - let t = TestContext::new_alice().await; - t.ctx - .set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat")) - .await?; - t.ctx - .set_config(Config::ConfiguredSentboxFolder, Some("Sent")) - .await?; - t.ctx - .set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" })) - .await?; - - if accepted_chat { - let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?; - ChatId::create_for_contact(&t.ctx, contact_id).await?; - } - let temp; - - let bytes = if setupmessage { - include_bytes!("../test-data/message/AutocryptSetupMessage.eml") - } else { - temp = format!( - "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ - {}\ - Subject: foo\n\ - Message-ID: \n\ - {}\ - Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ - \n\ - hello\n", - if outgoing { - "From: alice@example.org\nTo: bob@example.net\n" - } else { - "From: bob@example.net\nTo: alice@example.org\n" - }, - if chat_msg { "Chat-Version: 1.0\n" } else { "" }, - ); - temp.as_bytes() - }; - - let (headers, _) = mailparse::parse_headers(bytes)?; - let actual = if let Some(config) = - target_folder_cfg(&t, folder, get_folder_meaning_by_name(folder), &headers).await? - { - t.get_config(config).await? - } else { - None - }; - - let expected = if expected_destination == folder { - None - } else { - Some(expected_destination) - }; - assert_eq!(expected, actual.as_deref(), "For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"); - Ok(()) - } - - // chat_msg means that the message was sent by Delta Chat - // The tuples are (folder, mvbox_move, chat_msg, expected_destination) - const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[ - ("INBOX", false, false, "INBOX"), - ("INBOX", false, true, "INBOX"), - ("INBOX", true, false, "INBOX"), - ("INBOX", true, true, "DeltaChat"), - ("Sent", false, false, "Sent"), - ("Sent", false, true, "Sent"), - ("Sent", true, false, "Sent"), - ("Sent", true, true, "DeltaChat"), - ("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs - ("Spam", false, true, "INBOX"), - ("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs - ("Spam", true, true, "DeltaChat"), - ]; - - // These are the same as above, but non-chat messages in Spam stay in Spam - const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[ - ("INBOX", false, false, "INBOX"), - ("INBOX", false, true, "INBOX"), - ("INBOX", true, false, "INBOX"), - ("INBOX", true, true, "DeltaChat"), - ("Sent", false, false, "Sent"), - ("Sent", false, true, "Sent"), - ("Sent", true, false, "Sent"), - ("Sent", true, true, "DeltaChat"), - ("Spam", false, false, "Spam"), - ("Spam", false, true, "INBOX"), - ("Spam", true, false, "Spam"), - ("Spam", true, true, "DeltaChat"), - ]; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_target_folder_incoming_accepted() -> Result<()> { - for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT { - check_target_folder_combination( - folder, - *mvbox_move, - *chat_msg, - expected_destination, - true, - false, - false, - ) - .await?; - } - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_target_folder_incoming_request() -> Result<()> { - for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST { - check_target_folder_combination( - folder, - *mvbox_move, - *chat_msg, - expected_destination, - false, - false, - false, - ) - .await?; - } - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_target_folder_outgoing() -> Result<()> { - // Test outgoing emails - for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT { - check_target_folder_combination( - folder, - *mvbox_move, - *chat_msg, - expected_destination, - true, - true, - false, - ) - .await?; - } - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_target_folder_setupmsg() -> Result<()> { - // Test setupmessages - for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT { - check_target_folder_combination( - folder, - *mvbox_move, - *chat_msg, - if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam" - false, - true, - true, - ) - .await?; - } - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_get_imap_search_command() -> Result<()> { - let t = TestContext::new_alice().await; - assert_eq!( - get_imap_self_sent_search_command(&t.ctx).await?, - r#"FROM "alice@example.org""# - ); - - t.ctx.set_primary_self_addr("alice@another.com").await?; - assert_eq!( - get_imap_self_sent_search_command(&t.ctx).await?, - r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"# - ); - - t.ctx.set_primary_self_addr("alice@third.com").await?; - assert_eq!( - get_imap_self_sent_search_command(&t.ctx).await?, - r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"# - ); - - Ok(()) - } - - #[test] - fn test_uid_grouper() { - // Input: sequence of (rowid: i64, uid: u32, target: String) - // Output: sequence of (target: String, rowid_set: Vec, uid_set: String) - let grouper = UidGrouper::from([(1, 2, "INBOX".to_string())]); - let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); - assert_eq!(res, vec![("INBOX".to_string(), vec![1], "2".to_string())]); - - let grouper = UidGrouper::from([(1, 2, "INBOX".to_string()), (2, 3, "INBOX".to_string())]); - let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); - assert_eq!( - res, - vec![("INBOX".to_string(), vec![1, 2], "2:3".to_string())] - ); - - let grouper = UidGrouper::from([ - (1, 2, "INBOX".to_string()), - (2, 2, "INBOX".to_string()), - (3, 3, "INBOX".to_string()), - ]); - let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); - assert_eq!( - res, - vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())] - ); - } - - #[test] - fn test_setmetadata_device_token() { - assert_eq!( - format_setmetadata("INBOX", "foobarbaz"), - "SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)" - ); - assert_eq!( - format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"), - "SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)" - ); - } -} +mod imap_tests; diff --git a/src/imap/imap_tests.rs b/src/imap/imap_tests.rs new file mode 100644 index 000000000..1b81ffc09 --- /dev/null +++ b/src/imap/imap_tests.rs @@ -0,0 +1,337 @@ +use super::*; +use crate::test_utils::TestContext; + +#[test] +fn test_get_folder_meaning_by_name() { + assert_eq!(get_folder_meaning_by_name("Gesendet"), FolderMeaning::Sent); + assert_eq!(get_folder_meaning_by_name("GESENDET"), FolderMeaning::Sent); + assert_eq!(get_folder_meaning_by_name("gesendet"), FolderMeaning::Sent); + assert_eq!( + get_folder_meaning_by_name("Messages envoyés"), + FolderMeaning::Sent + ); + assert_eq!( + get_folder_meaning_by_name("mEsSaGes envoyÉs"), + FolderMeaning::Sent + ); + assert_eq!(get_folder_meaning_by_name("xxx"), FolderMeaning::Unknown); + assert_eq!(get_folder_meaning_by_name("SPAM"), FolderMeaning::Spam); + assert_eq!(get_folder_meaning_by_name("Trash"), FolderMeaning::Trash); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_uid_next_validity() { + let t = TestContext::new_alice().await; + assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); + assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 0); + + set_uidvalidity(&t.ctx, "Inbox", 7).await.unwrap(); + assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 7); + assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 0); + + set_uid_next(&t.ctx, "Inbox", 5).await.unwrap(); + set_uidvalidity(&t.ctx, "Inbox", 6).await.unwrap(); + assert_eq!(get_uid_next(&t.ctx, "Inbox").await.unwrap(), 5); + assert_eq!(get_uidvalidity(&t.ctx, "Inbox").await.unwrap(), 6); +} + +#[test] +fn test_build_sequence_sets() { + assert_eq!(build_sequence_sets(&[]).unwrap(), vec![]); + + let cases = vec![ + (vec![1], "1"), + (vec![3291], "3291"), + (vec![1, 3, 5, 7, 9, 11], "1,3,5,7,9,11"), + (vec![1, 2, 3], "1:3"), + (vec![1, 4, 5, 6], "1,4:6"), + ((1..=500).collect(), "1:500"), + (vec![3, 4, 8, 9, 10, 11, 39, 50, 2], "3:4,8:11,39,50,2"), + ]; + for (input, s) in cases { + assert_eq!( + build_sequence_sets(&input).unwrap(), + vec![(input, s.into())] + ); + } + + let has_number = |(uids, s): &(Vec, String), number| { + uids.iter().any(|&n| n == number) + && s.split(',').any(|n| n.parse::().unwrap() == number) + }; + + let numbers: Vec<_> = (2..=500).step_by(2).collect(); + let result = build_sequence_sets(&numbers).unwrap(); + for (_, set) in &result { + assert!(set.len() < 1010); + assert!(!set.ends_with(',')); + assert!(!set.starts_with(',')); + } + assert!(result.len() == 1); // these UIDs fit in one set + for &number in &numbers { + assert!(result.iter().any(|r| has_number(r, number))); + } + + let numbers: Vec<_> = (1..=1000).step_by(3).collect(); + let result = build_sequence_sets(&numbers).unwrap(); + for (_, set) in &result { + assert!(set.len() < 1010); + assert!(!set.ends_with(',')); + assert!(!set.starts_with(',')); + } + let (last_uids, last_str) = result.last().unwrap(); + assert_eq!( + last_uids.get((last_uids.len() - 2)..).unwrap(), + &[997, 1000] + ); + assert!(last_str.ends_with("997,1000")); + assert!(result.len() == 2); // This time we need 2 sets + for &number in &numbers { + assert!(result.iter().any(|r| has_number(r, number))); + } + + let numbers: Vec<_> = (30000000..=30002500).step_by(4).collect(); + let result = build_sequence_sets(&numbers).unwrap(); + for (_, set) in &result { + assert!(set.len() < 1010); + assert!(!set.ends_with(',')); + assert!(!set.starts_with(',')); + } + assert_eq!(result.len(), 6); + for &number in &numbers { + assert!(result.iter().any(|r| has_number(r, number))); + } +} + +async fn check_target_folder_combination( + folder: &str, + mvbox_move: bool, + chat_msg: bool, + expected_destination: &str, + accepted_chat: bool, + outgoing: bool, + setupmessage: bool, +) -> Result<()> { + println!("Testing: For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}"); + + let t = TestContext::new_alice().await; + t.ctx + .set_config(Config::ConfiguredMvboxFolder, Some("DeltaChat")) + .await?; + t.ctx + .set_config(Config::ConfiguredSentboxFolder, Some("Sent")) + .await?; + t.ctx + .set_config(Config::MvboxMove, Some(if mvbox_move { "1" } else { "0" })) + .await?; + + if accepted_chat { + let contact_id = Contact::create(&t.ctx, "", "bob@example.net").await?; + ChatId::create_for_contact(&t.ctx, contact_id).await?; + } + let temp; + + let bytes = if setupmessage { + include_bytes!("../../test-data/message/AutocryptSetupMessage.eml") + } else { + temp = format!( + "Received: (Postfix, from userid 1000); Mon, 4 Dec 2006 14:51:39 +0100 (CET)\n\ + {}\ + Subject: foo\n\ + Message-ID: \n\ + {}\ + Date: Sun, 22 Mar 2020 22:37:57 +0000\n\ + \n\ + hello\n", + if outgoing { + "From: alice@example.org\nTo: bob@example.net\n" + } else { + "From: bob@example.net\nTo: alice@example.org\n" + }, + if chat_msg { "Chat-Version: 1.0\n" } else { "" }, + ); + temp.as_bytes() + }; + + let (headers, _) = mailparse::parse_headers(bytes)?; + let actual = if let Some(config) = + target_folder_cfg(&t, folder, get_folder_meaning_by_name(folder), &headers).await? + { + t.get_config(config).await? + } else { + None + }; + + let expected = if expected_destination == folder { + None + } else { + Some(expected_destination) + }; + assert_eq!(expected, actual.as_deref(), "For folder {folder}, mvbox_move {mvbox_move}, chat_msg {chat_msg}, accepted {accepted_chat}, outgoing {outgoing}, setupmessage {setupmessage}: expected {expected:?}, got {actual:?}"); + Ok(()) +} + +// chat_msg means that the message was sent by Delta Chat +// The tuples are (folder, mvbox_move, chat_msg, expected_destination) +const COMBINATIONS_ACCEPTED_CHAT: &[(&str, bool, bool, &str)] = &[ + ("INBOX", false, false, "INBOX"), + ("INBOX", false, true, "INBOX"), + ("INBOX", true, false, "INBOX"), + ("INBOX", true, true, "DeltaChat"), + ("Sent", false, false, "Sent"), + ("Sent", false, true, "Sent"), + ("Sent", true, false, "Sent"), + ("Sent", true, true, "DeltaChat"), + ("Spam", false, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs + ("Spam", false, true, "INBOX"), + ("Spam", true, false, "INBOX"), // Move classical emails in accepted chats from Spam to Inbox, not 100% sure on this, we could also just never move non-chat-msgs + ("Spam", true, true, "DeltaChat"), +]; + +// These are the same as above, but non-chat messages in Spam stay in Spam +const COMBINATIONS_REQUEST: &[(&str, bool, bool, &str)] = &[ + ("INBOX", false, false, "INBOX"), + ("INBOX", false, true, "INBOX"), + ("INBOX", true, false, "INBOX"), + ("INBOX", true, true, "DeltaChat"), + ("Sent", false, false, "Sent"), + ("Sent", false, true, "Sent"), + ("Sent", true, false, "Sent"), + ("Sent", true, true, "DeltaChat"), + ("Spam", false, false, "Spam"), + ("Spam", false, true, "INBOX"), + ("Spam", true, false, "Spam"), + ("Spam", true, true, "DeltaChat"), +]; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_target_folder_incoming_accepted() -> Result<()> { + for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT { + check_target_folder_combination( + folder, + *mvbox_move, + *chat_msg, + expected_destination, + true, + false, + false, + ) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_target_folder_incoming_request() -> Result<()> { + for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_REQUEST { + check_target_folder_combination( + folder, + *mvbox_move, + *chat_msg, + expected_destination, + false, + false, + false, + ) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_target_folder_outgoing() -> Result<()> { + // Test outgoing emails + for (folder, mvbox_move, chat_msg, expected_destination) in COMBINATIONS_ACCEPTED_CHAT { + check_target_folder_combination( + folder, + *mvbox_move, + *chat_msg, + expected_destination, + true, + true, + false, + ) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_target_folder_setupmsg() -> Result<()> { + // Test setupmessages + for (folder, mvbox_move, chat_msg, _expected_destination) in COMBINATIONS_ACCEPTED_CHAT { + check_target_folder_combination( + folder, + *mvbox_move, + *chat_msg, + if folder == &"Spam" { "INBOX" } else { folder }, // Never move setup messages, except if they are in "Spam" + false, + true, + true, + ) + .await?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_get_imap_search_command() -> Result<()> { + let t = TestContext::new_alice().await; + assert_eq!( + get_imap_self_sent_search_command(&t.ctx).await?, + r#"FROM "alice@example.org""# + ); + + t.ctx.set_primary_self_addr("alice@another.com").await?; + assert_eq!( + get_imap_self_sent_search_command(&t.ctx).await?, + r#"OR (FROM "alice@another.com") (FROM "alice@example.org")"# + ); + + t.ctx.set_primary_self_addr("alice@third.com").await?; + assert_eq!( + get_imap_self_sent_search_command(&t.ctx).await?, + r#"OR (OR (FROM "alice@third.com") (FROM "alice@another.com")) (FROM "alice@example.org")"# + ); + + Ok(()) +} + +#[test] +fn test_uid_grouper() { + // Input: sequence of (rowid: i64, uid: u32, target: String) + // Output: sequence of (target: String, rowid_set: Vec, uid_set: String) + let grouper = UidGrouper::from([(1, 2, "INBOX".to_string())]); + let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); + assert_eq!(res, vec![("INBOX".to_string(), vec![1], "2".to_string())]); + + let grouper = UidGrouper::from([(1, 2, "INBOX".to_string()), (2, 3, "INBOX".to_string())]); + let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); + assert_eq!( + res, + vec![("INBOX".to_string(), vec![1, 2], "2:3".to_string())] + ); + + let grouper = UidGrouper::from([ + (1, 2, "INBOX".to_string()), + (2, 2, "INBOX".to_string()), + (3, 3, "INBOX".to_string()), + ]); + let res: Vec<(String, Vec, String)> = grouper.into_iter().collect(); + assert_eq!( + res, + vec![("INBOX".to_string(), vec![1, 2, 3], "2:3".to_string())] + ); +} + +#[test] +fn test_setmetadata_device_token() { + assert_eq!( + format_setmetadata("INBOX", "foobarbaz"), + "SETMETADATA \"INBOX\" (/private/devicetoken {9+}\r\nfoobarbaz)" + ); + assert_eq!( + format_setmetadata("INBOX", "foo\r\nbar\r\nbaz\r\n"), + "SETMETADATA \"INBOX\" (/private/devicetoken {15+}\r\nfoo\r\nbar\r\nbaz\r\n)" + ); +} diff --git a/src/qr.rs b/src/qr.rs index d3e80e4ff..f75f32ff9 100644 --- a/src/qr.rs +++ b/src/qr.rs @@ -947,924 +947,4 @@ fn normalize_address(addr: &str) -> Result { } #[cfg(test)] -mod tests { - use super::*; - use crate::aheader::EncryptPreference; - use crate::chat::{create_group_chat, ProtectionStatus}; - use crate::config::Config; - use crate::key::DcKey; - use crate::securejoin::get_securejoin_qr; - use crate::test_utils::{alice_keypair, TestContext}; - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_http() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "http://www.hello.com:80").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "http://www.hello.com:80".to_string(), - host: "www.hello.com".to_string(), - port: 80 - } - ); - - // If it has no explicit port, then it is not a proxy. - let qr = check_qr(&ctx.ctx, "http://www.hello.com").await?; - assert_eq!( - qr, - Qr::Url { - url: "http://www.hello.com".to_string(), - } - ); - - // If it has a path, then it is not a proxy. - let qr = check_qr(&ctx.ctx, "http://www.hello.com/").await?; - assert_eq!( - qr, - Qr::Url { - url: "http://www.hello.com/".to_string(), - } - ); - let qr = check_qr(&ctx.ctx, "http://www.hello.com/hello").await?; - assert_eq!( - qr, - Qr::Url { - url: "http://www.hello.com/hello".to_string(), - } - ); - - // Test that QR code whitespace is stripped. - // Users can copy-paste QR code contents and "scan" - // from the clipboard. - let qr = check_qr(&ctx.ctx, " \thttp://www.hello.com/hello \n\t \r\n ").await?; - assert_eq!( - qr, - Qr::Url { - url: "http://www.hello.com/hello".to_string(), - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_https() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "https://www.hello.com:443").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "https://www.hello.com:443".to_string(), - host: "www.hello.com".to_string(), - port: 443 - } - ); - - // If it has no explicit port, then it is not a proxy. - let qr = check_qr(&ctx.ctx, "https://www.hello.com").await?; - assert_eq!( - qr, - Qr::Url { - url: "https://www.hello.com".to_string(), - } - ); - - // If it has a path, then it is not a proxy. - let qr = check_qr(&ctx.ctx, "https://www.hello.com/").await?; - assert_eq!( - qr, - Qr::Url { - url: "https://www.hello.com/".to_string(), - } - ); - let qr = check_qr(&ctx.ctx, "https://www.hello.com/hello").await?; - assert_eq!( - qr, - Qr::Url { - url: "https://www.hello.com/hello".to_string(), - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_text() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "I am so cool").await?; - assert_eq!( - qr, - Qr::Text { - text: "I am so cool".to_string() - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_vcard() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "BEGIN:VCARD\nVERSION:3.0\nN:Last;First\nEMAIL;TYPE=INTERNET:stress@test.local\nEND:VCARD" - ).await?; - - if let Qr::Addr { contact_id, draft } = qr { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "stress@test.local"); - assert_eq!(contact.get_name(), "First Last"); - assert_eq!(contact.get_authname(), ""); - assert_eq!(contact.get_display_name(), "First Last"); - assert!(draft.is_none()); - } else { - bail!("Wrong QR code type"); - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_matmsg() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "MATMSG:TO:\n\nstress@test.local ; \n\nSUB:\n\nSubject here\n\nBODY:\n\nhelloworld\n;;", - ) - .await?; - - if let Qr::Addr { contact_id, draft } = qr { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "stress@test.local"); - assert!(draft.is_none()); - } else { - bail!("Wrong QR code type"); - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_mailto() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "mailto:stress@test.local?subject=hello&body=beautiful+world", - ) - .await?; - if let Qr::Addr { contact_id, draft } = qr { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "stress@test.local"); - assert_eq!(draft.unwrap(), "hello\nbeautiful world"); - } else { - bail!("Wrong QR code type"); - } - - let res = check_qr(&ctx.ctx, "mailto:no-questionmark@example.org").await?; - if let Qr::Addr { contact_id, draft } = res { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "no-questionmark@example.org"); - assert!(draft.is_none()); - } else { - bail!("Wrong QR code type"); - } - - let res = check_qr(&ctx.ctx, "mailto:no-addr").await; - assert!(res.is_err()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_smtp() -> Result<()> { - let ctx = TestContext::new().await; - - if let Qr::Addr { contact_id, draft } = - check_qr(&ctx.ctx, "SMTP:stress@test.local:subjecthello:bodyworld").await? - { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "stress@test.local"); - assert!(draft.is_none()); - } else { - bail!("Wrong QR code type"); - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_ideltachat_link() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "https://i.delta.chat/#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" - ).await?; - assert!(matches!(qr, Qr::AskVerifyGroup { .. })); - - let qr = check_qr( - &ctx.ctx, - "https://i.delta.chat#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" - ).await?; - assert!(matches!(qr, Qr::AskVerifyGroup { .. })); - - Ok(()) - } - - // macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too. - // see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7%23a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" - ).await?; - - assert!(matches!(qr, Qr::AskVerifyGroup { .. })); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_group() -> Result<()> { - let ctx = TestContext::new().await; - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" - ).await?; - if let Qr::AskVerifyGroup { - contact_id, - grpname, - .. - } = qr - { - assert_ne!(contact_id, ContactId::UNDEFINED); - assert_eq!(grpname, "test ? test !"); - } else { - bail!("Wrong QR code type"); - } - - // Test it again with lowercased "openpgp4fpr:" uri scheme - let ctx = TestContext::new().await; - let qr = check_qr( - &ctx.ctx, - "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" - ).await?; - if let Qr::AskVerifyGroup { - contact_id, - grpname, - .. - } = qr - { - assert_ne!(contact_id, ContactId::UNDEFINED); - assert_eq!(grpname, "test ? test !"); - - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "cli@deltachat.de"); - } else { - bail!("Wrong QR code type"); - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_invalid_token() -> Result<()> { - let ctx = TestContext::new().await; - - // Token cannot contain "/" - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL" - ).await?; - - assert!(matches!(qr, Qr::FprMismatch { .. })); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_secure_join() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" - ).await?; - - if let Qr::AskVerifyContact { contact_id, .. } = qr { - assert_ne!(contact_id, ContactId::UNDEFINED); - } else { - bail!("Wrong QR code type"); - } - - // Test it again with lowercased "openpgp4fpr:" uri scheme - let qr = check_qr( - &ctx.ctx, - "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" - ).await?; - - if let Qr::AskVerifyContact { contact_id, .. } = qr { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "cli@deltachat.de"); - assert_eq!(contact.get_authname(), "Jörn P. P."); - assert_eq!(contact.get_name(), ""); - } else { - bail!("Wrong QR code type"); - } - - // Regression test - let ctx = TestContext::new().await; - let qr = check_qr( - &ctx.ctx, - "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" - ).await?; - - if let Qr::AskVerifyContact { contact_id, .. } = qr { - let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; - assert_eq!(contact.get_addr(), "cli@deltachat.de"); - assert_eq!(contact.get_authname(), ""); - assert_eq!(contact.get_name(), ""); - } else { - bail!("Wrong QR code type"); - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_fingerprint() -> Result<()> { - let ctx = TestContext::new().await; - - let alice_contact_id = Contact::create(&ctx, "Alice", "alice@example.org") - .await - .context("failed to create contact")?; - let pub_key = alice_keypair().public; - let peerstate = Peerstate { - addr: "alice@example.org".to_string(), - last_seen: 1, - last_seen_autocrypt: 1, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(pub_key.clone()), - public_key_fingerprint: Some(pub_key.dc_fingerprint()), - gossip_key: None, - gossip_timestamp: 0, - gossip_key_fingerprint: None, - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - assert!( - peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), - "failed to save peerstate" - ); - - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org", - ) - .await?; - if let Qr::FprMismatch { contact_id, .. } = qr { - assert_eq!(contact_id, Some(alice_contact_id)); - } else { - bail!("Wrong QR code type"); - } - - let qr = check_qr( - &ctx.ctx, - &format!( - "OPENPGP4FPR:{}#a=alice@example.org", - pub_key.dc_fingerprint() - ), - ) - .await?; - if let Qr::FprOk { contact_id, .. } = qr { - assert_eq!(contact_id, alice_contact_id); - } else { - bail!("Wrong QR code type"); - } - - assert_eq!( - check_qr( - &ctx.ctx, - "OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org", - ) - .await?, - Qr::FprMismatch { contact_id: None } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_openpgp_without_addr() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "OPENPGP4FPR:1234567890123456789012345678901234567890", - ) - .await?; - assert_eq!( - qr, - Qr::FprWithoutAddr { - fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string() - } - ); - - // Test it again with lowercased "openpgp4fpr:" uri scheme - - let qr = check_qr( - &ctx.ctx, - "openpgp4fpr:1234567890123456789012345678901234567890", - ) - .await?; - assert_eq!( - qr, - Qr::FprWithoutAddr { - fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string() - } - ); - - let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await; - assert!(res.is_err()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_withdraw_verifycontact() -> Result<()> { - let alice = TestContext::new_alice().await; - let qr = get_securejoin_qr(&alice, None).await?; - - // scanning own verify-contact code offers withdrawing - assert!(matches!( - check_qr(&alice, &qr).await?, - Qr::WithdrawVerifyContact { .. } - )); - set_config_from_qr(&alice, &qr).await?; - - // scanning withdrawn verify-contact code offers reviving - assert!(matches!( - check_qr(&alice, &qr).await?, - Qr::ReviveVerifyContact { .. } - )); - set_config_from_qr(&alice, &qr).await?; - assert!(matches!( - check_qr(&alice, &qr).await?, - Qr::WithdrawVerifyContact { .. } - )); - - // someone else always scans as ask-verify-contact - let bob = TestContext::new_bob().await; - assert!(matches!( - check_qr(&bob, &qr).await?, - Qr::AskVerifyContact { .. } - )); - assert!(set_config_from_qr(&bob, &qr).await.is_err()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_withdraw_verifygroup() -> Result<()> { - let alice = TestContext::new_alice().await; - let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; - let qr = get_securejoin_qr(&alice, Some(chat_id)).await?; - - // scanning own verify-group code offers withdrawing - if let Qr::WithdrawVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? { - assert_eq!(grpname, "foo"); - } else { - bail!("Wrong QR type, expected WithdrawVerifyGroup"); - } - set_config_from_qr(&alice, &qr).await?; - - // scanning withdrawn verify-group code offers reviving - if let Qr::ReviveVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? { - assert_eq!(grpname, "foo"); - } else { - bail!("Wrong QR type, expected ReviveVerifyGroup"); - } - - // someone else always scans as ask-verify-group - let bob = TestContext::new_bob().await; - if let Qr::AskVerifyGroup { grpname, .. } = check_qr(&bob, &qr).await? { - assert_eq!(grpname, "foo"); - } else { - bail!("Wrong QR type, expected AskVerifyGroup"); - } - assert!(set_config_from_qr(&bob, &qr).await.is_err()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_and_apply_dclogin() -> Result<()> { - let ctx = TestContext::new().await; - - let result = check_qr(&ctx.ctx, "dclogin:usename+extension@host?p=1234&v=1").await?; - if let Qr::Login { address, options } = result { - assert_eq!(address, "usename+extension@host".to_owned()); - - if let LoginOptions::V1 { mail_pw, .. } = options { - assert_eq!(mail_pw, "1234".to_owned()); - } else { - bail!("wrong type") - } - } else { - bail!("wrong type") - } - - assert!(ctx.ctx.get_config(Config::Addr).await?.is_none()); - assert!(ctx.ctx.get_config(Config::MailPw).await?.is_none()); - - set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&v=1").await?; - assert_eq!( - ctx.ctx.get_config(Config::Addr).await?, - Some("username+extension@host".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::MailPw).await?, - Some("1234".to_owned()) - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_and_apply_dclogin_advanced_options() -> Result<()> { - let ctx = TestContext::new().await; - set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&spw=4321&sh=send.host&sp=7273&su=SendUser&ih=host.tld&ip=4343&iu=user&ipw=password&is=ssl&ic=1&sc=3&ss=plain&v=1").await?; - assert_eq!( - ctx.ctx.get_config(Config::Addr).await?, - Some("username+extension@host".to_owned()) - ); - - // `p=1234` is ignored, because `ipw=password` is set - - assert_eq!( - ctx.ctx.get_config(Config::MailServer).await?, - Some("host.tld".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::MailPort).await?, - Some("4343".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::MailUser).await?, - Some("user".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::MailPw).await?, - Some("password".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::MailSecurity).await?, - Some("1".to_owned()) // ssl - ); - assert_eq!( - ctx.ctx.get_config(Config::ImapCertificateChecks).await?, - Some("1".to_owned()) - ); - - assert_eq!( - ctx.ctx.get_config(Config::SendPw).await?, - Some("4321".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::SendServer).await?, - Some("send.host".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::SendPort).await?, - Some("7273".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::SendUser).await?, - Some("SendUser".to_owned()) - ); - - // `sc` option is actually ignored and `ic` is used instead - // because `smtp_certificate_checks` is deprecated. - assert_eq!( - ctx.ctx.get_config(Config::SmtpCertificateChecks).await?, - Some("1".to_owned()) - ); - assert_eq!( - ctx.ctx.get_config(Config::SendSecurity).await?, - Some("3".to_owned()) // plain - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_account() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", - ) - .await?; - assert_eq!( - qr, - Qr::Account { - domain: "example.org".to_string() - } - ); - - // Test it again with lowercased "dcaccount:" uri scheme - let qr = check_qr( - &ctx.ctx, - "dcaccount:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", - ) - .await?; - assert_eq!( - qr, - Qr::Account { - domain: "example.org".to_string() - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_webrtc_instance() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?; - assert_eq!( - qr, - Qr::WebrtcInstance { - domain: "basicurl.com".to_string(), - instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string() - } - ); - - // Test it again with mixcased "dcWebRTC:" uri scheme - let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?; - assert_eq!( - qr, - Qr::WebrtcInstance { - domain: "example.org".to_string(), - instance_pattern: "https://example.org/".to_string() - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_tg_socks_proxy() -> Result<()> { - let t = TestContext::new().await; - - let qr = check_qr(&t, "https://t.me/socks?server=84.53.239.95&port=4145").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "socks5://84.53.239.95:4145".to_string(), - host: "84.53.239.95".to_string(), - port: 4145, - } - ); - - let qr = check_qr(&t, "https://t.me/socks?server=foo.bar&port=123").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "socks5://foo.bar:123".to_string(), - host: "foo.bar".to_string(), - port: 123, - } - ); - - let qr = check_qr(&t, "https://t.me/socks?server=foo.baz").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "socks5://foo.baz:1080".to_string(), - host: "foo.baz".to_string(), - port: 1080, - } - ); - - let qr = check_qr( - &t, - "https://t.me/socks?server=foo.baz&port=12345&user=ada&pass=ms%21%2F%24", - ) - .await?; - assert_eq!( - qr, - Qr::Proxy { - url: "socks5://ada:ms%21%2F%24@foo.baz:12345".to_string(), - host: "foo.baz".to_string(), - port: 12345, - } - ); - - // wrong domain results in Qr:Url instead of Qr::Socks5Proxy - let qr = check_qr(&t, "https://not.me/socks?noserver=84.53.239.95&port=4145").await?; - assert_eq!( - qr, - Qr::Url { - url: "https://not.me/socks?noserver=84.53.239.95&port=4145".to_string() - } - ); - - let qr = check_qr(&t, "https://t.me/socks?noserver=84.53.239.95&port=4145").await; - assert!(qr.is_err()); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_account_bad_scheme() { - let ctx = TestContext::new().await; - let res = check_qr( - &ctx.ctx, - "DCACCOUNT:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", - ) - .await; - assert!(res.is_err()); - - // Test it again with lowercased "dcaccount:" uri scheme - let res = check_qr( - &ctx.ctx, - "dcaccount:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", - ) - .await; - assert!(res.is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_webrtc_instance_config_from_qr() -> Result<()> { - let ctx = TestContext::new().await; - - assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); - - let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await; - assert!(res.is_err()); - assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); - - let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await; - assert!(res.is_ok()); - assert_eq!( - ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), - "https://example.org/" - ); - - let res = - set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await; - assert!(res.is_ok()); - assert_eq!( - ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), - "basicwebrtc:https://foo.bar/?$ROOM&test" - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_proxy_config_from_qr() -> Result<()> { - let t = TestContext::new().await; - - assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, false); - - let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await; - assert!(res.is_ok()); - assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some("socks5://foo:666".to_string()) - ); - - // Test URL without port. - // - // Also check that whitespace is trimmed. - let res = set_config_from_qr(&t, " https://t.me/socks?server=1.2.3.4\n").await; - assert!(res.is_ok()); - assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some("socks5://1.2.3.4:1080\nsocks5://foo:666".to_string()) - ); - - // make sure, user&password are set when specified in the URL - // Password is an URL-encoded "x&%$X". - let res = - set_config_from_qr(&t, "https://t.me/socks?server=jau&user=Da&pass=x%26%25%24X").await; - assert!(res.is_ok()); - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some( - "socks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080\nsocks5://foo:666" - .to_string() - ) - ); - - // Scanning existing proxy brings it to the top in the list. - let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await; - assert!(res.is_ok()); - assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some( - "socks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080" - .to_string() - ) - ); - - set_config_from_qr( - &t, - "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1", - ) - .await?; - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some( - "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080" - .to_string() - ) - ); - - // SOCKS5 config does not have port 1080 explicitly specified, - // but should bring `socks5://1.2.3.4:1080` to the top instead of creating another entry. - set_config_from_qr(&t, "socks5://1.2.3.4").await?; - assert_eq!( - t.get_config(Config::ProxyUrl).await?, - Some( - "socks5://1.2.3.4:1080\nss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080" - .to_string() - ) - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_shadowsocks() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr( - &ctx.ctx, - "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1", - ) - .await?; - assert_eq!( - qr, - Qr::Proxy { - url: "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1".to_string(), - host: "192.168.100.1".to_string(), - port: 8888, - } - ); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_socks5() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx.ctx, "socks5://127.0.0.1:9050").await?; - assert_eq!( - qr, - Qr::Proxy { - url: "socks5://127.0.0.1:9050".to_string(), - host: "127.0.0.1".to_string(), - port: 9050, - } - ); - - Ok(()) - } - - /// Ensure that `DCBACKUP2` QR code does not fail to deserialize - /// because iroh changes the format of `NodeAddr` - /// as happened between iroh 0.29 and iroh 0.30 before. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_decode_backup() -> Result<()> { - let ctx = TestContext::new().await; - - let qr = check_qr(&ctx, r#"DCBACKUP2:TWSv6ZjDPa5eoxkocj7xMi8r&{"node_id":"9afc1ea5b4f543e5cdd7b7a21cd26aee7c0b1e1c2af26790896fbd8932a06e1e","relay_url":null,"direct_addresses":["192.168.1.10:12345"]}"#).await?; - assert!(matches!(qr, Qr::Backup2 { .. })); - - Ok(()) - } -} +mod qr_tests; diff --git a/src/qr/qr_tests.rs b/src/qr/qr_tests.rs new file mode 100644 index 000000000..51d20b8fd --- /dev/null +++ b/src/qr/qr_tests.rs @@ -0,0 +1,918 @@ +use super::*; +use crate::aheader::EncryptPreference; +use crate::chat::{create_group_chat, ProtectionStatus}; +use crate::config::Config; +use crate::key::DcKey; +use crate::securejoin::get_securejoin_qr; +use crate::test_utils::{alice_keypair, TestContext}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_http() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "http://www.hello.com:80").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "http://www.hello.com:80".to_string(), + host: "www.hello.com".to_string(), + port: 80 + } + ); + + // If it has no explicit port, then it is not a proxy. + let qr = check_qr(&ctx.ctx, "http://www.hello.com").await?; + assert_eq!( + qr, + Qr::Url { + url: "http://www.hello.com".to_string(), + } + ); + + // If it has a path, then it is not a proxy. + let qr = check_qr(&ctx.ctx, "http://www.hello.com/").await?; + assert_eq!( + qr, + Qr::Url { + url: "http://www.hello.com/".to_string(), + } + ); + let qr = check_qr(&ctx.ctx, "http://www.hello.com/hello").await?; + assert_eq!( + qr, + Qr::Url { + url: "http://www.hello.com/hello".to_string(), + } + ); + + // Test that QR code whitespace is stripped. + // Users can copy-paste QR code contents and "scan" + // from the clipboard. + let qr = check_qr(&ctx.ctx, " \thttp://www.hello.com/hello \n\t \r\n ").await?; + assert_eq!( + qr, + Qr::Url { + url: "http://www.hello.com/hello".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_https() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "https://www.hello.com:443").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "https://www.hello.com:443".to_string(), + host: "www.hello.com".to_string(), + port: 443 + } + ); + + // If it has no explicit port, then it is not a proxy. + let qr = check_qr(&ctx.ctx, "https://www.hello.com").await?; + assert_eq!( + qr, + Qr::Url { + url: "https://www.hello.com".to_string(), + } + ); + + // If it has a path, then it is not a proxy. + let qr = check_qr(&ctx.ctx, "https://www.hello.com/").await?; + assert_eq!( + qr, + Qr::Url { + url: "https://www.hello.com/".to_string(), + } + ); + let qr = check_qr(&ctx.ctx, "https://www.hello.com/hello").await?; + assert_eq!( + qr, + Qr::Url { + url: "https://www.hello.com/hello".to_string(), + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_text() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "I am so cool").await?; + assert_eq!( + qr, + Qr::Text { + text: "I am so cool".to_string() + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_vcard() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "BEGIN:VCARD\nVERSION:3.0\nN:Last;First\nEMAIL;TYPE=INTERNET:stress@test.local\nEND:VCARD", + ) + .await?; + + if let Qr::Addr { contact_id, draft } = qr { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "stress@test.local"); + assert_eq!(contact.get_name(), "First Last"); + assert_eq!(contact.get_authname(), ""); + assert_eq!(contact.get_display_name(), "First Last"); + assert!(draft.is_none()); + } else { + bail!("Wrong QR code type"); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_matmsg() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "MATMSG:TO:\n\nstress@test.local ; \n\nSUB:\n\nSubject here\n\nBODY:\n\nhelloworld\n;;", + ) + .await?; + + if let Qr::Addr { contact_id, draft } = qr { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "stress@test.local"); + assert!(draft.is_none()); + } else { + bail!("Wrong QR code type"); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_mailto() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "mailto:stress@test.local?subject=hello&body=beautiful+world", + ) + .await?; + if let Qr::Addr { contact_id, draft } = qr { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "stress@test.local"); + assert_eq!(draft.unwrap(), "hello\nbeautiful world"); + } else { + bail!("Wrong QR code type"); + } + + let res = check_qr(&ctx.ctx, "mailto:no-questionmark@example.org").await?; + if let Qr::Addr { contact_id, draft } = res { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "no-questionmark@example.org"); + assert!(draft.is_none()); + } else { + bail!("Wrong QR code type"); + } + + let res = check_qr(&ctx.ctx, "mailto:no-addr").await; + assert!(res.is_err()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_smtp() -> Result<()> { + let ctx = TestContext::new().await; + + if let Qr::Addr { contact_id, draft } = + check_qr(&ctx.ctx, "SMTP:stress@test.local:subjecthello:bodyworld").await? + { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "stress@test.local"); + assert!(draft.is_none()); + } else { + bail!("Wrong QR code type"); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_ideltachat_link() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "https://i.delta.chat/#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" + ).await?; + assert!(matches!(qr, Qr::AskVerifyGroup { .. })); + + let qr = check_qr( + &ctx.ctx, + "https://i.delta.chat#79252762C34C5096AF57958F4FC3D21A81B0F0A7&a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" + ).await?; + assert!(matches!(qr, Qr::AskVerifyGroup { .. })); + + Ok(()) +} + +// macOS and iOS sometimes replace the # with %23 (uri encode it), we should be able to parse this wrong format too. +// see issue https://github.com/deltachat/deltachat-core-rust/issues/1969 for more info +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_tolerance_for_issue_1969() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7%23a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" + ).await?; + + assert!(matches!(qr, Qr::AskVerifyGroup { .. })); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_group() -> Result<()> { + let ctx = TestContext::new().await; + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" + ).await?; + if let Qr::AskVerifyGroup { + contact_id, + grpname, + .. + } = qr + { + assert_ne!(contact_id, ContactId::UNDEFINED); + assert_eq!(grpname, "test ? test !"); + } else { + bail!("Wrong QR code type"); + } + + // Test it again with lowercased "openpgp4fpr:" uri scheme + let ctx = TestContext::new().await; + let qr = check_qr( + &ctx.ctx, + "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL9cxRL" + ).await?; + if let Qr::AskVerifyGroup { + contact_id, + grpname, + .. + } = qr + { + assert_ne!(contact_id, ContactId::UNDEFINED); + assert_eq!(grpname, "test ? test !"); + + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "cli@deltachat.de"); + } else { + bail!("Wrong QR code type"); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_invalid_token() -> Result<()> { + let ctx = TestContext::new().await; + + // Token cannot contain "/" + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&g=test%20%3F+test%20%21&x=h-0oKQf2CDK&i=9JEXlxAqGM0&s=0V7LzL/cxRL" + ).await?; + + assert!(matches!(qr, Qr::FprMismatch { .. })); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_secure_join() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" + ).await?; + + if let Qr::AskVerifyContact { contact_id, .. } = qr { + assert_ne!(contact_id, ContactId::UNDEFINED); + } else { + bail!("Wrong QR code type"); + } + + // Test it again with lowercased "openpgp4fpr:" uri scheme + let qr = check_qr( + &ctx.ctx, + "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=J%C3%B6rn%20P.+P.&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" + ).await?; + + if let Qr::AskVerifyContact { contact_id, .. } = qr { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "cli@deltachat.de"); + assert_eq!(contact.get_authname(), "Jörn P. P."); + assert_eq!(contact.get_name(), ""); + } else { + bail!("Wrong QR code type"); + } + + // Regression test + let ctx = TestContext::new().await; + let qr = check_qr( + &ctx.ctx, + "openpgp4fpr:79252762C34C5096AF57958F4FC3D21A81B0F0A7#a=cli%40deltachat.de&n=&i=TbnwJ6lSvD5&s=0ejvbdFSQxB" + ).await?; + + if let Qr::AskVerifyContact { contact_id, .. } = qr { + let contact = Contact::get_by_id(&ctx.ctx, contact_id).await?; + assert_eq!(contact.get_addr(), "cli@deltachat.de"); + assert_eq!(contact.get_authname(), ""); + assert_eq!(contact.get_name(), ""); + } else { + bail!("Wrong QR code type"); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_fingerprint() -> Result<()> { + let ctx = TestContext::new().await; + + let alice_contact_id = Contact::create(&ctx, "Alice", "alice@example.org") + .await + .context("failed to create contact")?; + let pub_key = alice_keypair().public; + let peerstate = Peerstate { + addr: "alice@example.org".to_string(), + last_seen: 1, + last_seen_autocrypt: 1, + prefer_encrypt: EncryptPreference::Mutual, + public_key: Some(pub_key.clone()), + public_key_fingerprint: Some(pub_key.dc_fingerprint()), + gossip_key: None, + gossip_timestamp: 0, + gossip_key_fingerprint: None, + verified_key: None, + verified_key_fingerprint: None, + verifier: None, + secondary_verified_key: None, + secondary_verified_key_fingerprint: None, + secondary_verifier: None, + backward_verified_key_id: None, + fingerprint_changed: false, + }; + assert!( + peerstate.save_to_db(&ctx.ctx.sql).await.is_ok(), + "failed to save peerstate" + ); + + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:1234567890123456789012345678901234567890#a=alice@example.org", + ) + .await?; + if let Qr::FprMismatch { contact_id, .. } = qr { + assert_eq!(contact_id, Some(alice_contact_id)); + } else { + bail!("Wrong QR code type"); + } + + let qr = check_qr( + &ctx.ctx, + &format!( + "OPENPGP4FPR:{}#a=alice@example.org", + pub_key.dc_fingerprint() + ), + ) + .await?; + if let Qr::FprOk { contact_id, .. } = qr { + assert_eq!(contact_id, alice_contact_id); + } else { + bail!("Wrong QR code type"); + } + + assert_eq!( + check_qr( + &ctx.ctx, + "OPENPGP4FPR:1234567890123456789012345678901234567890#a=bob@example.org", + ) + .await?, + Qr::FprMismatch { contact_id: None } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_openpgp_without_addr() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "OPENPGP4FPR:1234567890123456789012345678901234567890", + ) + .await?; + assert_eq!( + qr, + Qr::FprWithoutAddr { + fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string() + } + ); + + // Test it again with lowercased "openpgp4fpr:" uri scheme + + let qr = check_qr( + &ctx.ctx, + "openpgp4fpr:1234567890123456789012345678901234567890", + ) + .await?; + assert_eq!( + qr, + Qr::FprWithoutAddr { + fingerprint: "1234 5678 9012 3456 7890\n1234 5678 9012 3456 7890".to_string() + } + ); + + let res = check_qr(&ctx.ctx, "OPENPGP4FPR:12345678901234567890").await; + assert!(res.is_err()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_withdraw_verifycontact() -> Result<()> { + let alice = TestContext::new_alice().await; + let qr = get_securejoin_qr(&alice, None).await?; + + // scanning own verify-contact code offers withdrawing + assert!(matches!( + check_qr(&alice, &qr).await?, + Qr::WithdrawVerifyContact { .. } + )); + set_config_from_qr(&alice, &qr).await?; + + // scanning withdrawn verify-contact code offers reviving + assert!(matches!( + check_qr(&alice, &qr).await?, + Qr::ReviveVerifyContact { .. } + )); + set_config_from_qr(&alice, &qr).await?; + assert!(matches!( + check_qr(&alice, &qr).await?, + Qr::WithdrawVerifyContact { .. } + )); + + // someone else always scans as ask-verify-contact + let bob = TestContext::new_bob().await; + assert!(matches!( + check_qr(&bob, &qr).await?, + Qr::AskVerifyContact { .. } + )); + assert!(set_config_from_qr(&bob, &qr).await.is_err()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_withdraw_verifygroup() -> Result<()> { + let alice = TestContext::new_alice().await; + let chat_id = create_group_chat(&alice, ProtectionStatus::Unprotected, "foo").await?; + let qr = get_securejoin_qr(&alice, Some(chat_id)).await?; + + // scanning own verify-group code offers withdrawing + if let Qr::WithdrawVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? { + assert_eq!(grpname, "foo"); + } else { + bail!("Wrong QR type, expected WithdrawVerifyGroup"); + } + set_config_from_qr(&alice, &qr).await?; + + // scanning withdrawn verify-group code offers reviving + if let Qr::ReviveVerifyGroup { grpname, .. } = check_qr(&alice, &qr).await? { + assert_eq!(grpname, "foo"); + } else { + bail!("Wrong QR type, expected ReviveVerifyGroup"); + } + + // someone else always scans as ask-verify-group + let bob = TestContext::new_bob().await; + if let Qr::AskVerifyGroup { grpname, .. } = check_qr(&bob, &qr).await? { + assert_eq!(grpname, "foo"); + } else { + bail!("Wrong QR type, expected AskVerifyGroup"); + } + assert!(set_config_from_qr(&bob, &qr).await.is_err()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_and_apply_dclogin() -> Result<()> { + let ctx = TestContext::new().await; + + let result = check_qr(&ctx.ctx, "dclogin:usename+extension@host?p=1234&v=1").await?; + if let Qr::Login { address, options } = result { + assert_eq!(address, "usename+extension@host".to_owned()); + + if let LoginOptions::V1 { mail_pw, .. } = options { + assert_eq!(mail_pw, "1234".to_owned()); + } else { + bail!("wrong type") + } + } else { + bail!("wrong type") + } + + assert!(ctx.ctx.get_config(Config::Addr).await?.is_none()); + assert!(ctx.ctx.get_config(Config::MailPw).await?.is_none()); + + set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&v=1").await?; + assert_eq!( + ctx.ctx.get_config(Config::Addr).await?, + Some("username+extension@host".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPw).await?, + Some("1234".to_owned()) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_and_apply_dclogin_advanced_options() -> Result<()> { + let ctx = TestContext::new().await; + set_config_from_qr(&ctx.ctx, "dclogin:username+extension@host?p=1234&spw=4321&sh=send.host&sp=7273&su=SendUser&ih=host.tld&ip=4343&iu=user&ipw=password&is=ssl&ic=1&sc=3&ss=plain&v=1").await?; + assert_eq!( + ctx.ctx.get_config(Config::Addr).await?, + Some("username+extension@host".to_owned()) + ); + + // `p=1234` is ignored, because `ipw=password` is set + + assert_eq!( + ctx.ctx.get_config(Config::MailServer).await?, + Some("host.tld".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPort).await?, + Some("4343".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailUser).await?, + Some("user".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailPw).await?, + Some("password".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::MailSecurity).await?, + Some("1".to_owned()) // ssl + ); + assert_eq!( + ctx.ctx.get_config(Config::ImapCertificateChecks).await?, + Some("1".to_owned()) + ); + + assert_eq!( + ctx.ctx.get_config(Config::SendPw).await?, + Some("4321".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendServer).await?, + Some("send.host".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendPort).await?, + Some("7273".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendUser).await?, + Some("SendUser".to_owned()) + ); + + // `sc` option is actually ignored and `ic` is used instead + // because `smtp_certificate_checks` is deprecated. + assert_eq!( + ctx.ctx.get_config(Config::SmtpCertificateChecks).await?, + Some("1".to_owned()) + ); + assert_eq!( + ctx.ctx.get_config(Config::SendSecurity).await?, + Some("3".to_owned()) // plain + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_account() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "DCACCOUNT:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", + ) + .await?; + assert_eq!( + qr, + Qr::Account { + domain: "example.org".to_string() + } + ); + + // Test it again with lowercased "dcaccount:" uri scheme + let qr = check_qr( + &ctx.ctx, + "dcaccount:https://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", + ) + .await?; + assert_eq!( + qr, + Qr::Account { + domain: "example.org".to_string() + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_webrtc_instance() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://basicurl.com/$ROOM").await?; + assert_eq!( + qr, + Qr::WebrtcInstance { + domain: "basicurl.com".to_string(), + instance_pattern: "basicwebrtc:https://basicurl.com/$ROOM".to_string() + } + ); + + // Test it again with mixcased "dcWebRTC:" uri scheme + let qr = check_qr(&ctx.ctx, "dcWebRTC:https://example.org/").await?; + assert_eq!( + qr, + Qr::WebrtcInstance { + domain: "example.org".to_string(), + instance_pattern: "https://example.org/".to_string() + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_tg_socks_proxy() -> Result<()> { + let t = TestContext::new().await; + + let qr = check_qr(&t, "https://t.me/socks?server=84.53.239.95&port=4145").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "socks5://84.53.239.95:4145".to_string(), + host: "84.53.239.95".to_string(), + port: 4145, + } + ); + + let qr = check_qr(&t, "https://t.me/socks?server=foo.bar&port=123").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "socks5://foo.bar:123".to_string(), + host: "foo.bar".to_string(), + port: 123, + } + ); + + let qr = check_qr(&t, "https://t.me/socks?server=foo.baz").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "socks5://foo.baz:1080".to_string(), + host: "foo.baz".to_string(), + port: 1080, + } + ); + + let qr = check_qr( + &t, + "https://t.me/socks?server=foo.baz&port=12345&user=ada&pass=ms%21%2F%24", + ) + .await?; + assert_eq!( + qr, + Qr::Proxy { + url: "socks5://ada:ms%21%2F%24@foo.baz:12345".to_string(), + host: "foo.baz".to_string(), + port: 12345, + } + ); + + // wrong domain results in Qr:Url instead of Qr::Socks5Proxy + let qr = check_qr(&t, "https://not.me/socks?noserver=84.53.239.95&port=4145").await?; + assert_eq!( + qr, + Qr::Url { + url: "https://not.me/socks?noserver=84.53.239.95&port=4145".to_string() + } + ); + + let qr = check_qr(&t, "https://t.me/socks?noserver=84.53.239.95&port=4145").await; + assert!(qr.is_err()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_account_bad_scheme() { + let ctx = TestContext::new().await; + let res = check_qr( + &ctx.ctx, + "DCACCOUNT:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", + ) + .await; + assert!(res.is_err()); + + // Test it again with lowercased "dcaccount:" uri scheme + let res = check_qr( + &ctx.ctx, + "dcaccount:ftp://example.org/new_email?t=1w_7wDjgjelxeX884x96v3", + ) + .await; + assert!(res.is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_webrtc_instance_config_from_qr() -> Result<()> { + let ctx = TestContext::new().await; + + assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); + + let res = set_config_from_qr(&ctx.ctx, "badqr:https://example.org/").await; + assert!(res.is_err()); + assert!(ctx.ctx.get_config(Config::WebrtcInstance).await?.is_none()); + + let res = set_config_from_qr(&ctx.ctx, "dcwebrtc:https://example.org/").await; + assert!(res.is_ok()); + assert_eq!( + ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), + "https://example.org/" + ); + + let res = + set_config_from_qr(&ctx.ctx, "DCWEBRTC:basicwebrtc:https://foo.bar/?$ROOM&test").await; + assert!(res.is_ok()); + assert_eq!( + ctx.ctx.get_config(Config::WebrtcInstance).await?.unwrap(), + "basicwebrtc:https://foo.bar/?$ROOM&test" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_proxy_config_from_qr() -> Result<()> { + let t = TestContext::new().await; + + assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, false); + + let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await; + assert!(res.is_ok()); + assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some("socks5://foo:666".to_string()) + ); + + // Test URL without port. + // + // Also check that whitespace is trimmed. + let res = set_config_from_qr(&t, " https://t.me/socks?server=1.2.3.4\n").await; + assert!(res.is_ok()); + assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some("socks5://1.2.3.4:1080\nsocks5://foo:666".to_string()) + ); + + // make sure, user&password are set when specified in the URL + // Password is an URL-encoded "x&%$X". + let res = + set_config_from_qr(&t, "https://t.me/socks?server=jau&user=Da&pass=x%26%25%24X").await; + assert!(res.is_ok()); + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some( + "socks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080\nsocks5://foo:666".to_string() + ) + ); + + // Scanning existing proxy brings it to the top in the list. + let res = set_config_from_qr(&t, "https://t.me/socks?server=foo&port=666").await; + assert!(res.is_ok()); + assert_eq!(t.get_config_bool(Config::ProxyEnabled).await?, true); + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some( + "socks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080".to_string() + ) + ); + + set_config_from_qr( + &t, + "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1", + ) + .await?; + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some( + "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080\nsocks5://1.2.3.4:1080" + .to_string() + ) + ); + + // SOCKS5 config does not have port 1080 explicitly specified, + // but should bring `socks5://1.2.3.4:1080` to the top instead of creating another entry. + set_config_from_qr(&t, "socks5://1.2.3.4").await?; + assert_eq!( + t.get_config(Config::ProxyUrl).await?, + Some( + "socks5://1.2.3.4:1080\nss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1\nsocks5://foo:666\nsocks5://Da:x%26%25%24X@jau:1080" + .to_string() + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_shadowsocks() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr( + &ctx.ctx, + "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1", + ) + .await?; + assert_eq!( + qr, + Qr::Proxy { + url: "ss://YWVzLTEyOC1nY206dGVzdA@192.168.100.1:8888#Example1".to_string(), + host: "192.168.100.1".to_string(), + port: 8888, + } + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_socks5() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx.ctx, "socks5://127.0.0.1:9050").await?; + assert_eq!( + qr, + Qr::Proxy { + url: "socks5://127.0.0.1:9050".to_string(), + host: "127.0.0.1".to_string(), + port: 9050, + } + ); + + Ok(()) +} + +/// Ensure that `DCBACKUP2` QR code does not fail to deserialize +/// because iroh changes the format of `NodeAddr` +/// as happened between iroh 0.29 and iroh 0.30 before. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_decode_backup() -> Result<()> { + let ctx = TestContext::new().await; + + let qr = check_qr(&ctx, r#"DCBACKUP2:TWSv6ZjDPa5eoxkocj7xMi8r&{"node_id":"9afc1ea5b4f543e5cdd7b7a21cd26aee7c0b1e1c2af26790896fbd8932a06e1e","relay_url":null,"direct_addresses":["192.168.1.10:12345"]}"#).await?; + assert!(matches!(qr, Qr::Backup2 { .. })); + + Ok(()) +} diff --git a/src/securejoin.rs b/src/securejoin.rs index fd7208028..fe07555d1 100644 --- a/src/securejoin.rs +++ b/src/securejoin.rs @@ -744,850 +744,4 @@ fn encrypted_and_signed( } #[cfg(test)] -mod tests { - use deltachat_contact_tools::{ContactAddress, EmailAddress}; - - use super::*; - use crate::chat::{remove_contact_from_chat, CantSendReason}; - use crate::chatlist::Chatlist; - use crate::constants::{self, Chattype}; - use crate::imex::{imex, ImexMode}; - use crate::receive_imf::receive_imf; - use crate::stock_str::{self, chat_protection_enabled}; - use crate::test_utils::{get_chat_msg, TimeShiftFalsePositiveNote}; - use crate::test_utils::{TestContext, TestContextManager}; - use crate::tools::SystemTime; - use std::collections::HashSet; - use std::time::Duration; - - #[derive(PartialEq)] - enum SetupContactCase { - Normal, - CheckProtectionTimestamp, - WrongAliceGossip, - SecurejoinWaitTimeout, - AliceIsBot, - AliceHasName, - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact() { - test_setup_contact_ex(SetupContactCase::Normal).await - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_protection_timestamp() { - test_setup_contact_ex(SetupContactCase::CheckProtectionTimestamp).await - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_wrong_alice_gossip() { - test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_wait_timeout() { - test_setup_contact_ex(SetupContactCase::SecurejoinWaitTimeout).await - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_alice_is_bot() { - test_setup_contact_ex(SetupContactCase::AliceIsBot).await - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_alice_has_name() { - test_setup_contact_ex(SetupContactCase::AliceHasName).await - } - - async fn test_setup_contact_ex(case: SetupContactCase) { - let _n = TimeShiftFalsePositiveNote; - - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let alice_addr = &alice.get_config(Config::Addr).await.unwrap().unwrap(); - if case == SetupContactCase::AliceHasName { - alice - .set_config(Config::Displayname, Some("Alice")) - .await - .unwrap(); - } - let bob = tcm.bob().await; - bob.set_config(Config::Displayname, Some("Bob Examplenet")) - .await - .unwrap(); - let alice_auto_submitted_hdr; - match case { - SetupContactCase::AliceIsBot => { - alice.set_config_bool(Config::Bot, true).await.unwrap(); - alice_auto_submitted_hdr = "Auto-Submitted: auto-generated"; - } - _ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied", - }; - for t in [&alice, &bob] { - t.set_config_bool(Config::VerifiedOneOnOneChats, true) - .await - .unwrap(); - } - - assert_eq!( - Chatlist::try_load(&alice, 0, None, None) - .await - .unwrap() - .len(), - 0 - ); - assert_eq!( - Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), - 0 - ); - - // Step 1: Generate QR-code, ChatId(0) indicates setup-contact - let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); - // We want Bob to learn Alice's name from their messages, not from the QR code. - alice - .set_config(Config::Displayname, Some("Alice Exampleorg")) - .await - .unwrap(); - - // Step 2: Bob scans QR-code, sends vc-request - join_securejoin(&bob.ctx, &qr).await.unwrap(); - assert_eq!( - Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), - 1 - ); - let contact_alice_id = Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let sent = bob.pop_sent_msg().await; - assert!(!sent.payload.contains("Bob Examplenet")); - assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap()); - let msg = alice.parse_msg(&sent).await; - assert!(!msg.was_encrypted()); - assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request"); - assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some()); - assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); - - // Step 3: Alice receives vc-request, sends vc-auth-required - alice.recv_msg_trash(&sent).await; - assert_eq!( - Chatlist::try_load(&alice, 0, None, None) - .await - .unwrap() - .len(), - 1 - ); - - let sent = alice.pop_sent_msg().await; - assert!(sent.payload.contains(alice_auto_submitted_hdr)); - assert!(!sent.payload.contains("Alice Exampleorg")); - let msg = bob.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vc-auth-required" - ); - let bob_chat = bob.create_chat(&alice).await; - assert_eq!(bob_chat.can_send(&bob).await.unwrap(), false); - assert_eq!( - bob_chat.why_cant_send(&bob).await.unwrap(), - Some(CantSendReason::SecurejoinWait) - ); - if case == SetupContactCase::SecurejoinWaitTimeout { - SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT)); - assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); - } - - // Step 4: Bob receives vc-auth-required, sends vc-request-with-auth - bob.recv_msg_trash(&sent).await; - let bob_chat = bob.create_chat(&alice).await; - assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); - - // Check Bob emitted the JoinerProgress event. - let event = bob - .evtracker - .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) - .await; - match event { - EventType::SecurejoinJoinerProgress { - contact_id, - progress, - } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - assert_eq!(contact_id, alice_contact_id); - assert_eq!(progress, 400); - } - _ => unreachable!(), - } - - // Check Bob sent the right message. - let sent = bob.pop_sent_msg().await; - assert!(sent.payload.contains("Auto-Submitted: auto-replied")); - assert!(!sent.payload.contains("Bob Examplenet")); - let mut msg = alice.parse_msg(&sent).await; - let vc_request_with_auth_ts_sent = msg - .get_header(HeaderDef::Date) - .and_then(|value| mailparse::dateparse(value).ok()) - .unwrap(); - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vc-request-with-auth" - ); - assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx) - .await - .unwrap() - .dc_fingerprint(); - assert_eq!( - *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), - bob_fp.hex() - ); - - if case == SetupContactCase::WrongAliceGossip { - let wrong_pubkey = load_self_public_key(&bob).await.unwrap(); - let alice_pubkey = msg - .gossiped_keys - .insert(alice_addr.to_string(), wrong_pubkey) - .unwrap(); - let contact_bob = alice.add_or_lookup_contact(&bob).await; - let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id) - .await - .unwrap(); - assert_eq!(handshake_msg, HandshakeMessage::Ignore); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); - - msg.gossiped_keys - .insert(alice_addr.to_string(), alice_pubkey) - .unwrap(); - let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id) - .await - .unwrap(); - assert_eq!(handshake_msg, HandshakeMessage::Ignore); - assert!(contact_bob.is_verified(&alice.ctx).await.unwrap()); - return; - } - - // Alice should not yet have Bob verified - let contact_bob_id = - Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); - assert_eq!(contact_bob.get_authname(), ""); - - if case == SetupContactCase::CheckProtectionTimestamp { - SystemTime::shift(Duration::from_secs(3600)); - } - - // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm - alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); - assert_eq!(contact_bob.get_authname(), "Bob Examplenet"); - assert!(contact_bob.get_name().is_empty()); - assert_eq!(contact_bob.is_bot(), false); - - // exactly one one-to-one chat should be visible for both now - // (check this before calling alice.create_chat() explicitly below) - assert_eq!( - Chatlist::try_load(&alice, 0, None, None) - .await - .unwrap() - .len(), - 1 - ); - assert_eq!( - Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), - 1 - ); - - // Check Alice got the verified message in her 1:1 chat. - { - let chat = alice.create_chat(&bob).await; - let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await; - assert!(msg.is_info()); - let expected_text = chat_protection_enabled(&alice).await; - assert_eq!(msg.get_text(), expected_text); - if case == SetupContactCase::CheckProtectionTimestamp { - assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1); - } - } - - // Make sure Alice hasn't yet sent their name to Bob. - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id) - .await - .unwrap(); - match case { - SetupContactCase::AliceHasName => assert_eq!(contact_alice.get_authname(), "Alice"), - _ => assert_eq!(contact_alice.get_authname(), ""), - }; - - // Check Alice sent the right message to Bob. - let sent = alice.pop_sent_msg().await; - assert!(sent.payload.contains(alice_auto_submitted_hdr)); - assert!(!sent.payload.contains("Alice Exampleorg")); - let msg = bob.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vc-contact-confirm" - ); - - // Bob should not yet have Alice verified - assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), false); - - // Step 7: Bob receives vc-contact-confirm - bob.recv_msg_trash(&sent).await; - assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true); - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id) - .await - .unwrap(); - assert_eq!(contact_alice.get_authname(), "Alice Exampleorg"); - assert!(contact_alice.get_name().is_empty()); - assert_eq!(contact_alice.is_bot(), case == SetupContactCase::AliceIsBot); - - if case != SetupContactCase::SecurejoinWaitTimeout { - // Later we check that the timeout message isn't added to the already protected chat. - SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT + 1)); - assert_eq!( - bob_chat - .check_securejoin_wait(&bob, constants::SECUREJOIN_WAIT_TIMEOUT) - .await - .unwrap(), - 0 - ); - } - - // Check Bob got expected info messages in his 1:1 chat. - let msg_cnt: usize = match case { - SetupContactCase::SecurejoinWaitTimeout => 3, - _ => 2, - }; - let mut i = 0..msg_cnt; - let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; - assert!(msg.is_info()); - assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await); - if case == SetupContactCase::SecurejoinWaitTimeout { - let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; - assert!(msg.is_info()); - assert_eq!( - msg.get_text(), - stock_str::securejoin_wait_timeout(&bob).await - ); - } - let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; - assert!(msg.is_info()); - assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_bad_qr() { - let bob = TestContext::new_bob().await; - let ret = join_securejoin(&bob.ctx, "not a qr code").await; - assert!(ret.is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_bob_knows_alice() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - // Ensure Bob knows Alice_FP - let alice_pubkey = load_self_public_key(&alice.ctx).await?; - let peerstate = Peerstate { - addr: "alice@example.org".into(), - last_seen: 10, - last_seen_autocrypt: 10, - prefer_encrypt: EncryptPreference::Mutual, - public_key: Some(alice_pubkey.clone()), - public_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), - gossip_key: Some(alice_pubkey.clone()), - gossip_timestamp: 10, - gossip_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), - verified_key: None, - verified_key_fingerprint: None, - verifier: None, - secondary_verified_key: None, - secondary_verified_key_fingerprint: None, - secondary_verifier: None, - backward_verified_key_id: None, - fingerprint_changed: false, - }; - peerstate.save_to_db(&bob.ctx.sql).await?; - - // Step 1: Generate QR-code, ChatId(0) indicates setup-contact - let qr = get_securejoin_qr(&alice.ctx, None).await?; - - // Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request - join_securejoin(&bob.ctx, &qr).await.unwrap(); - - // Check Bob emitted the JoinerProgress event. - let event = bob - .evtracker - .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) - .await; - match event { - EventType::SecurejoinJoinerProgress { - contact_id, - progress, - } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - assert_eq!(contact_id, alice_contact_id); - assert_eq!(progress, 400); - } - _ => unreachable!(), - } - - // Check Bob sent the right handshake message. - let sent = bob.pop_sent_msg().await; - let msg = alice.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vc-request-with-auth" - ); - assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); - assert_eq!( - *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), - bob_fp.hex() - ); - - // Alice should not yet have Bob verified - let (contact_bob_id, _modified) = Contact::add_or_lookup( - &alice.ctx, - "", - &ContactAddress::new("bob@example.net")?, - Origin::ManuallyCreated, - ) - .await?; - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); - - // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm - alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); - - let sent = alice.pop_sent_msg().await; - let msg = bob.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vc-contact-confirm" - ); - - // Bob should not yet have Alice verified - let contact_alice_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; - assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false); - - // Step 7: Bob receives vc-contact-confirm - bob.recv_msg_trash(&sent).await; - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_setup_contact_concurrent_calls() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - // do a scan that is not working as claire is never responding - let qr_stale = "OPENPGP4FPR:1234567890123456789012345678901234567890#a=claire%40foo.de&n=&i=12345678901&s=23456789012"; - let claire_id = join_securejoin(&bob, qr_stale).await?; - let chat = Chat::load_from_db(&bob, claire_id).await?; - assert!(!claire_id.is_special()); - assert_eq!(chat.typ, Chattype::Single); - assert!(bob.pop_sent_msg().await.payload().contains("claire@foo.de")); - - // subsequent scans shall abort existing ones or run concurrently - - // but they must not fail as otherwise the whole qr scanning becomes unusable until restart. - let qr = get_securejoin_qr(&alice, None).await?; - let alice_id = join_securejoin(&bob, &qr).await?; - let chat = Chat::load_from_db(&bob, alice_id).await?; - assert!(!alice_id.is_special()); - assert_eq!(chat.typ, Chattype::Single); - assert_ne!(claire_id, alice_id); - assert!(bob - .pop_sent_msg() - .await - .payload() - .contains("alice@example.org")); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_secure_join() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - // We start with empty chatlists. - assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0); - assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0); - - let alice_chatid = - chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?; - - // Step 1: Generate QR-code, secure-join implied by chatid - let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)) - .await - .unwrap(); - - // Step 2: Bob scans QR-code, sends vg-request - let bob_chatid = join_securejoin(&bob.ctx, &qr).await?; - assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1); - - let sent = bob.pop_sent_msg().await; - assert_eq!( - sent.recipient(), - EmailAddress::new("alice@example.org").unwrap() - ); - let msg = alice.parse_msg(&sent).await; - assert!(!msg.was_encrypted()); - assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request"); - assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some()); - assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); - - // Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`, - // but it was only used by Alice in `vg-request-with-auth`. - // New Delta Chat versions do not use `Secure-Join-Group` header at all - // and it is deprecated. - // Now `Secure-Join-Group` header - // is only sent in `vg-request-with-auth` for compatibility. - assert!(msg.get_header(HeaderDef::SecureJoinGroup).is_none()); - - // Step 3: Alice receives vg-request, sends vg-auth-required - alice.recv_msg_trash(&sent).await; - - let sent = alice.pop_sent_msg().await; - assert!(sent.payload.contains("Auto-Submitted: auto-replied")); - let msg = bob.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vg-auth-required" - ); - - // Step 4: Bob receives vg-auth-required, sends vg-request-with-auth - bob.recv_msg_trash(&sent).await; - let sent = bob.pop_sent_msg().await; - - // Check Bob emitted the JoinerProgress event. - let event = bob - .evtracker - .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) - .await; - match event { - EventType::SecurejoinJoinerProgress { - contact_id, - progress, - } => { - let alice_contact_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - assert_eq!(contact_id, alice_contact_id); - assert_eq!(progress, 400); - } - _ => unreachable!(), - } - - // Check Bob sent the right handshake message. - assert!(sent.payload.contains("Auto-Submitted: auto-replied")); - let msg = alice.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vg-request-with-auth" - ); - assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); - let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); - assert_eq!( - *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), - bob_fp.hex() - ); - - // Alice should not yet have Bob verified - let contact_bob_id = - Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await? - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); - - // Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added - alice.recv_msg_trash(&sent).await; - assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); - - let sent = alice.pop_sent_msg().await; - let msg = bob.parse_msg(&sent).await; - assert!(msg.was_encrypted()); - assert_eq!( - msg.get_header(HeaderDef::SecureJoin).unwrap(), - "vg-member-added" - ); - // Formally this message is auto-submitted, but as the member addition is a result of an - // explicit user action, the Auto-Submitted header shouldn't be present. Otherwise it would - // be strange to have it in "member-added" messages of verified groups only. - assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); - // This is a two-member group, but Alice must Autocrypt-gossip to her other devices. - assert!(msg.get_header(HeaderDef::AutocryptGossip).is_some()); - - { - // Now Alice's chat with Bob should still be hidden, the verified message should - // appear in the group chat. - - let chat = alice.get_chat(&bob).await; - assert_eq!( - chat.blocked, - Blocked::Yes, - "Alice's 1:1 chat with Bob is not hidden" - ); - // There should be 3 messages in the chat: - // - The ChatProtectionEnabled message - // - You added member bob@example.net - let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await; - assert!(msg.is_info()); - let expected_text = chat_protection_enabled(&alice).await; - assert_eq!(msg.get_text(), expected_text); - } - - // Bob should not yet have Alice verified - let contact_alice_id = - Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; - assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false); - - // Step 7: Bob receives vg-member-added - bob.recv_msg(&sent).await; - { - // Bob has Alice verified, message shows up in the group chat. - assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); - let chat = bob.get_chat(&alice).await; - assert_eq!( - chat.blocked, - Blocked::Yes, - "Bob's 1:1 chat with Alice is not hidden" - ); - for item in chat::get_chat_msgs(&bob.ctx, bob_chatid).await.unwrap() { - if let chat::ChatItem::Message { msg_id } = item { - let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap(); - let text = msg.get_text(); - println!("msg {msg_id} text: {text}"); - } - } - } - - let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?; - assert!(bob_chat.is_protected()); - assert!(bob_chat.typ == Chattype::Group); - - // On this "happy path", Alice and Bob get only a group-chat where all information are added to. - // The one-to-one chats are used internally for the hidden handshake messages, - // however, should not be visible in the UIs. - assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1); - assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1); - - // If Bob then sends a direct message to alice, however, the one-to-one with Alice should appear. - let bobs_chat_with_alice = bob.create_chat(&alice).await; - let sent = bob.send_text(bobs_chat_with_alice.id, "Hello").await; - alice.recv_msg(&sent).await; - assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 2); - assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 2); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_adhoc_group_no_qr() -> Result<()> { - let alice = TestContext::new_alice().await; - - let mime = br#"Subject: First thread -Message-ID: first@example.org -To: Alice , Bob -From: Claire -Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no - -First thread."#; - - receive_imf(&alice, mime, false).await?; - let msg = alice.get_last_msg().await; - let chat_id = msg.chat_id; - - assert!(get_securejoin_qr(&alice, Some(chat_id)).await.is_err()); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_unknown_sender() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - - tcm.execute_securejoin(&alice, &bob).await; - - let alice_chat_id = alice - .create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob]) - .await; - - let sent = alice.send_text(alice_chat_id, "Hi!").await; - let bob_chat_id = bob.recv_msg(&sent).await.chat_id; - - let sent = bob.send_text(bob_chat_id, "Hi hi!").await; - - let alice_bob_contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?; - remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; - alice.pop_sent_msg().await; - - // The message from Bob is delivered late, Bob is already removed. - let msg = alice.recv_msg(&sent).await; - assert_eq!(msg.text, "Hi hi!"); - assert_eq!(msg.error.unwrap(), "Unknown sender for this chat."); - - Ok(()) - } - - /// Tests that Bob gets Alice as verified - /// if `vc-contact-confirm` is lost but Alice then sends - /// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_lost_contact_confirm() { - let mut tcm = TestContextManager::new(); - let alice = tcm.alice().await; - let bob = tcm.bob().await; - for t in [&alice, &bob] { - t.set_config_bool(Config::VerifiedOneOnOneChats, true) - .await - .unwrap(); - } - - let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); - join_securejoin(&bob.ctx, &qr).await.unwrap(); - - // vc-request - let sent = bob.pop_sent_msg().await; - alice.recv_msg_trash(&sent).await; - - // vc-auth-required - let sent = alice.pop_sent_msg().await; - bob.recv_msg_trash(&sent).await; - - // vc-request-with-auth - let sent = bob.pop_sent_msg().await; - alice.recv_msg_trash(&sent).await; - - // Alice has Bob verified now. - let contact_bob_id = - Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) - .await - .unwrap(); - assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); - - // Alice sends vc-contact-confirm, but it gets lost. - let _sent_vc_contact_confirm = alice.pop_sent_msg().await; - - // Bob should not yet have Alice verified - let contact_alice_id = - Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown) - .await - .expect("Error looking up contact") - .expect("Contact not found"); - let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); - assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false); - - // Alice sends a text message to Bob. - let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await; - let chat_id = received_hello.chat_id; - let chat = Chat::load_from_db(&bob, chat_id).await.unwrap(); - assert_eq!(chat.is_protected(), true); - - // Received text message in a verified 1:1 chat results in backward verification - // and Bob now marks alice as verified. - let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); - assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true); - } - - /// An unencrypted message with already known Autocrypt key, but sent from another address, - /// means that it's rather a new contact sharing the same key than the existing one changed its - /// address, otherwise it would already have our key to encrypt. - /// - /// This is a regression test for a bug where DC wrongly executed AEAP in this case. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_shared_bobs_key() -> Result<()> { - let mut tcm = TestContextManager::new(); - let alice = &tcm.alice().await; - let bob = &tcm.bob().await; - let bob_addr = &bob.get_config(Config::Addr).await?.unwrap(); - - tcm.execute_securejoin(bob, alice).await; - - let export_dir = tempfile::tempdir().unwrap(); - imex(bob, ImexMode::ExportSelfKeys, export_dir.path(), None).await?; - let bob2 = &TestContext::new().await; - let bob2_addr = "bob2@example.net"; - bob2.configure_addr(bob2_addr).await; - imex(bob2, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; - - tcm.execute_securejoin(bob2, alice).await; - - let bob3 = &TestContext::new().await; - let bob3_addr = "bob3@example.net"; - bob3.configure_addr(bob3_addr).await; - imex(bob3, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; - tcm.send_recv(bob3, alice, "hi Alice!").await; - let msg = tcm.send_recv(alice, bob3, "hi Bob3!").await; - assert!(msg.get_showpadlock()); - - let mut bob_ids = HashSet::new(); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob_addr, Origin::Unknown) - .await? - .unwrap(), - ); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob2_addr, Origin::Unknown) - .await? - .unwrap(), - ); - bob_ids.insert( - Contact::lookup_id_by_addr(alice, bob3_addr, Origin::Unknown) - .await? - .unwrap(), - ); - assert_eq!(bob_ids.len(), 3); - Ok(()) - } -} +mod securejoin_tests; diff --git a/src/securejoin/securejoin_tests.rs b/src/securejoin/securejoin_tests.rs new file mode 100644 index 000000000..e2072d4a9 --- /dev/null +++ b/src/securejoin/securejoin_tests.rs @@ -0,0 +1,841 @@ +use deltachat_contact_tools::{ContactAddress, EmailAddress}; + +use super::*; +use crate::chat::{remove_contact_from_chat, CantSendReason}; +use crate::chatlist::Chatlist; +use crate::constants::{self, Chattype}; +use crate::imex::{imex, ImexMode}; +use crate::receive_imf::receive_imf; +use crate::stock_str::{self, chat_protection_enabled}; +use crate::test_utils::{get_chat_msg, TimeShiftFalsePositiveNote}; +use crate::test_utils::{TestContext, TestContextManager}; +use crate::tools::SystemTime; +use std::collections::HashSet; +use std::time::Duration; + +#[derive(PartialEq)] +enum SetupContactCase { + Normal, + CheckProtectionTimestamp, + WrongAliceGossip, + SecurejoinWaitTimeout, + AliceIsBot, + AliceHasName, +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact() { + test_setup_contact_ex(SetupContactCase::Normal).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_protection_timestamp() { + test_setup_contact_ex(SetupContactCase::CheckProtectionTimestamp).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_wrong_alice_gossip() { + test_setup_contact_ex(SetupContactCase::WrongAliceGossip).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_wait_timeout() { + test_setup_contact_ex(SetupContactCase::SecurejoinWaitTimeout).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_alice_is_bot() { + test_setup_contact_ex(SetupContactCase::AliceIsBot).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_alice_has_name() { + test_setup_contact_ex(SetupContactCase::AliceHasName).await +} + +async fn test_setup_contact_ex(case: SetupContactCase) { + let _n = TimeShiftFalsePositiveNote; + + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let alice_addr = &alice.get_config(Config::Addr).await.unwrap().unwrap(); + if case == SetupContactCase::AliceHasName { + alice + .set_config(Config::Displayname, Some("Alice")) + .await + .unwrap(); + } + let bob = tcm.bob().await; + bob.set_config(Config::Displayname, Some("Bob Examplenet")) + .await + .unwrap(); + let alice_auto_submitted_hdr; + match case { + SetupContactCase::AliceIsBot => { + alice.set_config_bool(Config::Bot, true).await.unwrap(); + alice_auto_submitted_hdr = "Auto-Submitted: auto-generated"; + } + _ => alice_auto_submitted_hdr = "Auto-Submitted: auto-replied", + }; + for t in [&alice, &bob] { + t.set_config_bool(Config::VerifiedOneOnOneChats, true) + .await + .unwrap(); + } + + assert_eq!( + Chatlist::try_load(&alice, 0, None, None) + .await + .unwrap() + .len(), + 0 + ); + assert_eq!( + Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), + 0 + ); + + // Step 1: Generate QR-code, ChatId(0) indicates setup-contact + let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); + // We want Bob to learn Alice's name from their messages, not from the QR code. + alice + .set_config(Config::Displayname, Some("Alice Exampleorg")) + .await + .unwrap(); + + // Step 2: Bob scans QR-code, sends vc-request + join_securejoin(&bob.ctx, &qr).await.unwrap(); + assert_eq!( + Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), + 1 + ); + let contact_alice_id = Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let sent = bob.pop_sent_msg().await; + assert!(!sent.payload.contains("Bob Examplenet")); + assert_eq!(sent.recipient(), EmailAddress::new(alice_addr).unwrap()); + let msg = alice.parse_msg(&sent).await; + assert!(!msg.was_encrypted()); + assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vc-request"); + assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some()); + assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); + + // Step 3: Alice receives vc-request, sends vc-auth-required + alice.recv_msg_trash(&sent).await; + assert_eq!( + Chatlist::try_load(&alice, 0, None, None) + .await + .unwrap() + .len(), + 1 + ); + + let sent = alice.pop_sent_msg().await; + assert!(sent.payload.contains(alice_auto_submitted_hdr)); + assert!(!sent.payload.contains("Alice Exampleorg")); + let msg = bob.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vc-auth-required" + ); + let bob_chat = bob.create_chat(&alice).await; + assert_eq!(bob_chat.can_send(&bob).await.unwrap(), false); + assert_eq!( + bob_chat.why_cant_send(&bob).await.unwrap(), + Some(CantSendReason::SecurejoinWait) + ); + if case == SetupContactCase::SecurejoinWaitTimeout { + SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT)); + assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); + } + + // Step 4: Bob receives vc-auth-required, sends vc-request-with-auth + bob.recv_msg_trash(&sent).await; + let bob_chat = bob.create_chat(&alice).await; + assert_eq!(bob_chat.can_send(&bob).await.unwrap(), true); + + // Check Bob emitted the JoinerProgress event. + let event = bob + .evtracker + .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) + .await; + match event { + EventType::SecurejoinJoinerProgress { + contact_id, + progress, + } => { + let alice_contact_id = + Contact::lookup_id_by_addr(&bob.ctx, alice_addr, Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + assert_eq!(contact_id, alice_contact_id); + assert_eq!(progress, 400); + } + _ => unreachable!(), + } + + // Check Bob sent the right message. + let sent = bob.pop_sent_msg().await; + assert!(sent.payload.contains("Auto-Submitted: auto-replied")); + assert!(!sent.payload.contains("Bob Examplenet")); + let mut msg = alice.parse_msg(&sent).await; + let vc_request_with_auth_ts_sent = msg + .get_header(HeaderDef::Date) + .and_then(|value| mailparse::dateparse(value).ok()) + .unwrap(); + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vc-request-with-auth" + ); + assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); + let bob_fp = load_self_public_key(&bob.ctx) + .await + .unwrap() + .dc_fingerprint(); + assert_eq!( + *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), + bob_fp.hex() + ); + + if case == SetupContactCase::WrongAliceGossip { + let wrong_pubkey = load_self_public_key(&bob).await.unwrap(); + let alice_pubkey = msg + .gossiped_keys + .insert(alice_addr.to_string(), wrong_pubkey) + .unwrap(); + let contact_bob = alice.add_or_lookup_contact(&bob).await; + let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id) + .await + .unwrap(); + assert_eq!(handshake_msg, HandshakeMessage::Ignore); + assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); + + msg.gossiped_keys + .insert(alice_addr.to_string(), alice_pubkey) + .unwrap(); + let handshake_msg = handle_securejoin_handshake(&alice, &msg, contact_bob.id) + .await + .unwrap(); + assert_eq!(handshake_msg, HandshakeMessage::Ignore); + assert!(contact_bob.is_verified(&alice.ctx).await.unwrap()); + return; + } + + // Alice should not yet have Bob verified + let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) + .await + .unwrap(); + assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), false); + assert_eq!(contact_bob.get_authname(), ""); + + if case == SetupContactCase::CheckProtectionTimestamp { + SystemTime::shift(Duration::from_secs(3600)); + } + + // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm + alice.recv_msg_trash(&sent).await; + assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); + let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) + .await + .unwrap(); + assert_eq!(contact_bob.get_authname(), "Bob Examplenet"); + assert!(contact_bob.get_name().is_empty()); + assert_eq!(contact_bob.is_bot(), false); + + // exactly one one-to-one chat should be visible for both now + // (check this before calling alice.create_chat() explicitly below) + assert_eq!( + Chatlist::try_load(&alice, 0, None, None) + .await + .unwrap() + .len(), + 1 + ); + assert_eq!( + Chatlist::try_load(&bob, 0, None, None).await.unwrap().len(), + 1 + ); + + // Check Alice got the verified message in her 1:1 chat. + { + let chat = alice.create_chat(&bob).await; + let msg = get_chat_msg(&alice, chat.get_id(), 0, 1).await; + assert!(msg.is_info()); + let expected_text = chat_protection_enabled(&alice).await; + assert_eq!(msg.get_text(), expected_text); + if case == SetupContactCase::CheckProtectionTimestamp { + assert_eq!(msg.timestamp_sort, vc_request_with_auth_ts_sent + 1); + } + } + + // Make sure Alice hasn't yet sent their name to Bob. + let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id) + .await + .unwrap(); + match case { + SetupContactCase::AliceHasName => assert_eq!(contact_alice.get_authname(), "Alice"), + _ => assert_eq!(contact_alice.get_authname(), ""), + }; + + // Check Alice sent the right message to Bob. + let sent = alice.pop_sent_msg().await; + assert!(sent.payload.contains(alice_auto_submitted_hdr)); + assert!(!sent.payload.contains("Alice Exampleorg")); + let msg = bob.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vc-contact-confirm" + ); + + // Bob should not yet have Alice verified + assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), false); + + // Step 7: Bob receives vc-contact-confirm + bob.recv_msg_trash(&sent).await; + assert_eq!(contact_alice.is_verified(&bob.ctx).await.unwrap(), true); + let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id) + .await + .unwrap(); + assert_eq!(contact_alice.get_authname(), "Alice Exampleorg"); + assert!(contact_alice.get_name().is_empty()); + assert_eq!(contact_alice.is_bot(), case == SetupContactCase::AliceIsBot); + + if case != SetupContactCase::SecurejoinWaitTimeout { + // Later we check that the timeout message isn't added to the already protected chat. + SystemTime::shift(Duration::from_secs(constants::SECUREJOIN_WAIT_TIMEOUT + 1)); + assert_eq!( + bob_chat + .check_securejoin_wait(&bob, constants::SECUREJOIN_WAIT_TIMEOUT) + .await + .unwrap(), + 0 + ); + } + + // Check Bob got expected info messages in his 1:1 chat. + let msg_cnt: usize = match case { + SetupContactCase::SecurejoinWaitTimeout => 3, + _ => 2, + }; + let mut i = 0..msg_cnt; + let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; + assert!(msg.is_info()); + assert_eq!(msg.get_text(), stock_str::securejoin_wait(&bob).await); + if case == SetupContactCase::SecurejoinWaitTimeout { + let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; + assert!(msg.is_info()); + assert_eq!( + msg.get_text(), + stock_str::securejoin_wait_timeout(&bob).await + ); + } + let msg = get_chat_msg(&bob, bob_chat.get_id(), i.next().unwrap(), msg_cnt).await; + assert!(msg.is_info()); + assert_eq!(msg.get_text(), chat_protection_enabled(&bob).await); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_bad_qr() { + let bob = TestContext::new_bob().await; + let ret = join_securejoin(&bob.ctx, "not a qr code").await; + assert!(ret.is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_bob_knows_alice() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + // Ensure Bob knows Alice_FP + let alice_pubkey = load_self_public_key(&alice.ctx).await?; + let peerstate = Peerstate { + addr: "alice@example.org".into(), + last_seen: 10, + last_seen_autocrypt: 10, + prefer_encrypt: EncryptPreference::Mutual, + public_key: Some(alice_pubkey.clone()), + public_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), + gossip_key: Some(alice_pubkey.clone()), + gossip_timestamp: 10, + gossip_key_fingerprint: Some(alice_pubkey.dc_fingerprint()), + verified_key: None, + verified_key_fingerprint: None, + verifier: None, + secondary_verified_key: None, + secondary_verified_key_fingerprint: None, + secondary_verifier: None, + backward_verified_key_id: None, + fingerprint_changed: false, + }; + peerstate.save_to_db(&bob.ctx.sql).await?; + + // Step 1: Generate QR-code, ChatId(0) indicates setup-contact + let qr = get_securejoin_qr(&alice.ctx, None).await?; + + // Step 2+4: Bob scans QR-code, sends vc-request-with-auth, skipping vc-request + join_securejoin(&bob.ctx, &qr).await.unwrap(); + + // Check Bob emitted the JoinerProgress event. + let event = bob + .evtracker + .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) + .await; + match event { + EventType::SecurejoinJoinerProgress { + contact_id, + progress, + } => { + let alice_contact_id = + Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + assert_eq!(contact_id, alice_contact_id); + assert_eq!(progress, 400); + } + _ => unreachable!(), + } + + // Check Bob sent the right handshake message. + let sent = bob.pop_sent_msg().await; + let msg = alice.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vc-request-with-auth" + ); + assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); + let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); + assert_eq!( + *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), + bob_fp.hex() + ); + + // Alice should not yet have Bob verified + let (contact_bob_id, _modified) = Contact::add_or_lookup( + &alice.ctx, + "", + &ContactAddress::new("bob@example.net")?, + Origin::ManuallyCreated, + ) + .await?; + let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; + assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); + + // Step 5+6: Alice receives vc-request-with-auth, sends vc-contact-confirm + alice.recv_msg_trash(&sent).await; + assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); + + let sent = alice.pop_sent_msg().await; + let msg = bob.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vc-contact-confirm" + ); + + // Bob should not yet have Alice verified + let contact_alice_id = + Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; + assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false); + + // Step 7: Bob receives vc-contact-confirm + bob.recv_msg_trash(&sent).await; + assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_setup_contact_concurrent_calls() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + // do a scan that is not working as claire is never responding + let qr_stale = "OPENPGP4FPR:1234567890123456789012345678901234567890#a=claire%40foo.de&n=&i=12345678901&s=23456789012"; + let claire_id = join_securejoin(&bob, qr_stale).await?; + let chat = Chat::load_from_db(&bob, claire_id).await?; + assert!(!claire_id.is_special()); + assert_eq!(chat.typ, Chattype::Single); + assert!(bob.pop_sent_msg().await.payload().contains("claire@foo.de")); + + // subsequent scans shall abort existing ones or run concurrently - + // but they must not fail as otherwise the whole qr scanning becomes unusable until restart. + let qr = get_securejoin_qr(&alice, None).await?; + let alice_id = join_securejoin(&bob, &qr).await?; + let chat = Chat::load_from_db(&bob, alice_id).await?; + assert!(!alice_id.is_special()); + assert_eq!(chat.typ, Chattype::Single); + assert_ne!(claire_id, alice_id); + assert!(bob + .pop_sent_msg() + .await + .payload() + .contains("alice@example.org")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_secure_join() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + // We start with empty chatlists. + assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 0); + assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0); + + let alice_chatid = + chat::create_group_chat(&alice.ctx, ProtectionStatus::Protected, "the chat").await?; + + // Step 1: Generate QR-code, secure-join implied by chatid + let qr = get_securejoin_qr(&alice.ctx, Some(alice_chatid)) + .await + .unwrap(); + + // Step 2: Bob scans QR-code, sends vg-request + let bob_chatid = join_securejoin(&bob.ctx, &qr).await?; + assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1); + + let sent = bob.pop_sent_msg().await; + assert_eq!( + sent.recipient(), + EmailAddress::new("alice@example.org").unwrap() + ); + let msg = alice.parse_msg(&sent).await; + assert!(!msg.was_encrypted()); + assert_eq!(msg.get_header(HeaderDef::SecureJoin).unwrap(), "vg-request"); + assert!(msg.get_header(HeaderDef::SecureJoinInvitenumber).is_some()); + assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); + + // Old Delta Chat core sent `Secure-Join-Group` header in `vg-request`, + // but it was only used by Alice in `vg-request-with-auth`. + // New Delta Chat versions do not use `Secure-Join-Group` header at all + // and it is deprecated. + // Now `Secure-Join-Group` header + // is only sent in `vg-request-with-auth` for compatibility. + assert!(msg.get_header(HeaderDef::SecureJoinGroup).is_none()); + + // Step 3: Alice receives vg-request, sends vg-auth-required + alice.recv_msg_trash(&sent).await; + + let sent = alice.pop_sent_msg().await; + assert!(sent.payload.contains("Auto-Submitted: auto-replied")); + let msg = bob.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vg-auth-required" + ); + + // Step 4: Bob receives vg-auth-required, sends vg-request-with-auth + bob.recv_msg_trash(&sent).await; + let sent = bob.pop_sent_msg().await; + + // Check Bob emitted the JoinerProgress event. + let event = bob + .evtracker + .get_matching(|evt| matches!(evt, EventType::SecurejoinJoinerProgress { .. })) + .await; + match event { + EventType::SecurejoinJoinerProgress { + contact_id, + progress, + } => { + let alice_contact_id = + Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + assert_eq!(contact_id, alice_contact_id); + assert_eq!(progress, 400); + } + _ => unreachable!(), + } + + // Check Bob sent the right handshake message. + assert!(sent.payload.contains("Auto-Submitted: auto-replied")); + let msg = alice.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vg-request-with-auth" + ); + assert!(msg.get_header(HeaderDef::SecureJoinAuth).is_some()); + let bob_fp = load_self_public_key(&bob.ctx).await?.dc_fingerprint(); + assert_eq!( + *msg.get_header(HeaderDef::SecureJoinFingerprint).unwrap(), + bob_fp.hex() + ); + + // Alice should not yet have Bob verified + let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) + .await? + .expect("Contact not found"); + let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id).await?; + assert_eq!(contact_bob.is_verified(&alice.ctx).await?, false); + + // Step 5+6: Alice receives vg-request-with-auth, sends vg-member-added + alice.recv_msg_trash(&sent).await; + assert_eq!(contact_bob.is_verified(&alice.ctx).await?, true); + + let sent = alice.pop_sent_msg().await; + let msg = bob.parse_msg(&sent).await; + assert!(msg.was_encrypted()); + assert_eq!( + msg.get_header(HeaderDef::SecureJoin).unwrap(), + "vg-member-added" + ); + // Formally this message is auto-submitted, but as the member addition is a result of an + // explicit user action, the Auto-Submitted header shouldn't be present. Otherwise it would + // be strange to have it in "member-added" messages of verified groups only. + assert!(msg.get_header(HeaderDef::AutoSubmitted).is_none()); + // This is a two-member group, but Alice must Autocrypt-gossip to her other devices. + assert!(msg.get_header(HeaderDef::AutocryptGossip).is_some()); + + { + // Now Alice's chat with Bob should still be hidden, the verified message should + // appear in the group chat. + + let chat = alice.get_chat(&bob).await; + assert_eq!( + chat.blocked, + Blocked::Yes, + "Alice's 1:1 chat with Bob is not hidden" + ); + // There should be 3 messages in the chat: + // - The ChatProtectionEnabled message + // - You added member bob@example.net + let msg = get_chat_msg(&alice, alice_chatid, 0, 2).await; + assert!(msg.is_info()); + let expected_text = chat_protection_enabled(&alice).await; + assert_eq!(msg.get_text(), expected_text); + } + + // Bob should not yet have Alice verified + let contact_alice_id = + Contact::lookup_id_by_addr(&bob.ctx, "alice@example.org", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let contact_alice = Contact::get_by_id(&bob.ctx, contact_alice_id).await?; + assert_eq!(contact_bob.is_verified(&bob.ctx).await?, false); + + // Step 7: Bob receives vg-member-added + bob.recv_msg(&sent).await; + { + // Bob has Alice verified, message shows up in the group chat. + assert_eq!(contact_alice.is_verified(&bob.ctx).await?, true); + let chat = bob.get_chat(&alice).await; + assert_eq!( + chat.blocked, + Blocked::Yes, + "Bob's 1:1 chat with Alice is not hidden" + ); + for item in chat::get_chat_msgs(&bob.ctx, bob_chatid).await.unwrap() { + if let chat::ChatItem::Message { msg_id } = item { + let msg = Message::load_from_db(&bob.ctx, msg_id).await.unwrap(); + let text = msg.get_text(); + println!("msg {msg_id} text: {text}"); + } + } + } + + let bob_chat = Chat::load_from_db(&bob.ctx, bob_chatid).await?; + assert!(bob_chat.is_protected()); + assert!(bob_chat.typ == Chattype::Group); + + // On this "happy path", Alice and Bob get only a group-chat where all information are added to. + // The one-to-one chats are used internally for the hidden handshake messages, + // however, should not be visible in the UIs. + assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 1); + assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1); + + // If Bob then sends a direct message to alice, however, the one-to-one with Alice should appear. + let bobs_chat_with_alice = bob.create_chat(&alice).await; + let sent = bob.send_text(bobs_chat_with_alice.id, "Hello").await; + alice.recv_msg(&sent).await; + assert_eq!(Chatlist::try_load(&alice, 0, None, None).await?.len(), 2); + assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 2); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_adhoc_group_no_qr() -> Result<()> { + let alice = TestContext::new_alice().await; + + let mime = br#"Subject: First thread +Message-ID: first@example.org +To: Alice , Bob +From: Claire +Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no + +First thread."#; + + receive_imf(&alice, mime, false).await?; + let msg = alice.get_last_msg().await; + let chat_id = msg.chat_id; + + assert!(get_securejoin_qr(&alice, Some(chat_id)).await.is_err()); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_unknown_sender() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + + tcm.execute_securejoin(&alice, &bob).await; + + let alice_chat_id = alice + .create_group_with_members(ProtectionStatus::Protected, "Group with Bob", &[&bob]) + .await; + + let sent = alice.send_text(alice_chat_id, "Hi!").await; + let bob_chat_id = bob.recv_msg(&sent).await.chat_id; + + let sent = bob.send_text(bob_chat_id, "Hi hi!").await; + + let alice_bob_contact_id = Contact::create(&alice, "Bob", "bob@example.net").await?; + remove_contact_from_chat(&alice, alice_chat_id, alice_bob_contact_id).await?; + alice.pop_sent_msg().await; + + // The message from Bob is delivered late, Bob is already removed. + let msg = alice.recv_msg(&sent).await; + assert_eq!(msg.text, "Hi hi!"); + assert_eq!(msg.error.unwrap(), "Unknown sender for this chat."); + + Ok(()) +} + +/// Tests that Bob gets Alice as verified +/// if `vc-contact-confirm` is lost but Alice then sends +/// a message to Bob in a verified 1:1 chat with a `Chat-Verified` header. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_lost_contact_confirm() { + let mut tcm = TestContextManager::new(); + let alice = tcm.alice().await; + let bob = tcm.bob().await; + for t in [&alice, &bob] { + t.set_config_bool(Config::VerifiedOneOnOneChats, true) + .await + .unwrap(); + } + + let qr = get_securejoin_qr(&alice.ctx, None).await.unwrap(); + join_securejoin(&bob.ctx, &qr).await.unwrap(); + + // vc-request + let sent = bob.pop_sent_msg().await; + alice.recv_msg_trash(&sent).await; + + // vc-auth-required + let sent = alice.pop_sent_msg().await; + bob.recv_msg_trash(&sent).await; + + // vc-request-with-auth + let sent = bob.pop_sent_msg().await; + alice.recv_msg_trash(&sent).await; + + // Alice has Bob verified now. + let contact_bob_id = Contact::lookup_id_by_addr(&alice.ctx, "bob@example.net", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let contact_bob = Contact::get_by_id(&alice.ctx, contact_bob_id) + .await + .unwrap(); + assert_eq!(contact_bob.is_verified(&alice.ctx).await.unwrap(), true); + + // Alice sends vc-contact-confirm, but it gets lost. + let _sent_vc_contact_confirm = alice.pop_sent_msg().await; + + // Bob should not yet have Alice verified + let contact_alice_id = Contact::lookup_id_by_addr(&bob, "alice@example.org", Origin::Unknown) + .await + .expect("Error looking up contact") + .expect("Contact not found"); + let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); + assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), false); + + // Alice sends a text message to Bob. + let received_hello = tcm.send_recv(&alice, &bob, "Hello!").await; + let chat_id = received_hello.chat_id; + let chat = Chat::load_from_db(&bob, chat_id).await.unwrap(); + assert_eq!(chat.is_protected(), true); + + // Received text message in a verified 1:1 chat results in backward verification + // and Bob now marks alice as verified. + let contact_alice = Contact::get_by_id(&bob, contact_alice_id).await.unwrap(); + assert_eq!(contact_alice.is_verified(&bob).await.unwrap(), true); +} + +/// An unencrypted message with already known Autocrypt key, but sent from another address, +/// means that it's rather a new contact sharing the same key than the existing one changed its +/// address, otherwise it would already have our key to encrypt. +/// +/// This is a regression test for a bug where DC wrongly executed AEAP in this case. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_shared_bobs_key() -> Result<()> { + let mut tcm = TestContextManager::new(); + let alice = &tcm.alice().await; + let bob = &tcm.bob().await; + let bob_addr = &bob.get_config(Config::Addr).await?.unwrap(); + + tcm.execute_securejoin(bob, alice).await; + + let export_dir = tempfile::tempdir().unwrap(); + imex(bob, ImexMode::ExportSelfKeys, export_dir.path(), None).await?; + let bob2 = &TestContext::new().await; + let bob2_addr = "bob2@example.net"; + bob2.configure_addr(bob2_addr).await; + imex(bob2, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; + + tcm.execute_securejoin(bob2, alice).await; + + let bob3 = &TestContext::new().await; + let bob3_addr = "bob3@example.net"; + bob3.configure_addr(bob3_addr).await; + imex(bob3, ImexMode::ImportSelfKeys, export_dir.path(), None).await?; + tcm.send_recv(bob3, alice, "hi Alice!").await; + let msg = tcm.send_recv(alice, bob3, "hi Bob3!").await; + assert!(msg.get_showpadlock()); + + let mut bob_ids = HashSet::new(); + bob_ids.insert( + Contact::lookup_id_by_addr(alice, bob_addr, Origin::Unknown) + .await? + .unwrap(), + ); + bob_ids.insert( + Contact::lookup_id_by_addr(alice, bob2_addr, Origin::Unknown) + .await? + .unwrap(), + ); + bob_ids.insert( + Contact::lookup_id_by_addr(alice, bob3_addr, Origin::Unknown) + .await? + .unwrap(), + ); + assert_eq!(bob_ids.len(), 3); + Ok(()) +} diff --git a/src/sql.rs b/src/sql.rs index efc68267b..9f0e791d2 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1044,377 +1044,4 @@ async fn prune_tombstones(sql: &Sql) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - use crate::{test_utils::TestContext, EventType}; - - #[test] - fn test_maybe_add_file() { - let mut files = Default::default(); - maybe_add_file(&mut files, "$BLOBDIR/hello"); - maybe_add_file(&mut files, "$BLOBDIR/world.txt"); - maybe_add_file(&mut files, "world2.txt"); - maybe_add_file(&mut files, "$BLOBDIR"); - - assert!(files.contains("hello")); - assert!(files.contains("world.txt")); - assert!(!files.contains("world2.txt")); - assert!(!files.contains("$BLOBDIR")); - } - - #[test] - fn test_is_file_in_use() { - let mut files = Default::default(); - maybe_add_file(&mut files, "$BLOBDIR/hello"); - maybe_add_file(&mut files, "$BLOBDIR/world.txt"); - maybe_add_file(&mut files, "world2.txt"); - - assert!(is_file_in_use(&files, None, "hello")); - assert!(!is_file_in_use(&files, Some(".txt"), "hello")); - assert!(is_file_in_use(&files, Some("-suffix"), "world.txt-suffix")); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_table_exists() { - let t = TestContext::new().await; - assert!(t.ctx.sql.table_exists("msgs").await.unwrap()); - assert!(!t.ctx.sql.table_exists("foobar").await.unwrap()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_col_exists() { - let t = TestContext::new().await; - assert!(t.ctx.sql.col_exists("msgs", "mime_modified").await.unwrap()); - assert!(!t.ctx.sql.col_exists("msgs", "foobar").await.unwrap()); - assert!(!t.ctx.sql.col_exists("foobar", "foobar").await.unwrap()); - } - - /// Tests that auto_vacuum is enabled for new databases. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_auto_vacuum() -> Result<()> { - let t = TestContext::new().await; - - let query_only = true; - let auto_vacuum = t - .sql - .call(query_only, |conn| { - let auto_vacuum = conn.pragma_query_value(None, "auto_vacuum", |row| { - let auto_vacuum: i32 = row.get(0)?; - Ok(auto_vacuum) - })?; - Ok(auto_vacuum) - }) - .await?; - - // auto_vacuum=2 is the same as auto_vacuum=INCREMENTAL - assert_eq!(auto_vacuum, 2); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_housekeeping_db_closed() { - let t = TestContext::new().await; - - let avatar_src = t.dir.path().join("avatar.png"); - let avatar_bytes = include_bytes!("../test-data/image/avatar64x64.png"); - tokio::fs::write(&avatar_src, avatar_bytes).await.unwrap(); - t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) - .await - .unwrap(); - - let event_source = t.get_event_emitter(); - - let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); - assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]); - - t.sql.close().await; - housekeeping(&t).await.unwrap(); // housekeeping should emit warnings but not fail - t.sql.open(&t, "".to_string()).await.unwrap(); - - let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); - assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]); - - while let Ok(event) = event_source.try_recv() { - match event.typ { - EventType::Info(s) => assert!( - !s.contains("Keeping new unreferenced file"), - "File {s} was almost deleted, only reason it was kept is that it was created recently (as the tests don't run for a long time)" - ), - EventType::Error(s) => panic!("{}", s), - _ => {} - } - } - } - - /// Regression test for a bug where housekeeping deleted drafts since their - /// `hidden` flag is set. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_housekeeping_dont_delete_drafts() { - let t = TestContext::new_alice().await; - - let chat = t.create_chat_with_contact("bob", "bob@example.com").await; - let mut new_draft = Message::new_text("This is my draft".to_string()); - chat.id.set_draft(&t, Some(&mut new_draft)).await.unwrap(); - - housekeeping(&t).await.unwrap(); - - let loaded_draft = chat.id.get_draft(&t).await.unwrap(); - assert_eq!(loaded_draft.unwrap().text, "This is my draft"); - } - - /// Tests that `housekeeping` deletes the blobs backup dir which is created normally by - /// `imex::import_backup`. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_housekeeping_delete_blobs_backup_dir() { - let t = TestContext::new_alice().await; - let dir = t.get_blobdir().join(BLOBS_BACKUP_NAME); - tokio::fs::create_dir(&dir).await.unwrap(); - tokio::fs::write(dir.join("f"), "").await.unwrap(); - housekeeping(&t).await.unwrap(); - tokio::fs::create_dir(&dir).await.unwrap(); - } - - /// Regression test. - /// - /// Previously the code checking for existence of `config` table - /// checked it with `PRAGMA table_info("config")` but did not - /// drain `SqlitePool.fetch` result, only using the first row - /// returned. As a result, prepared statement for `PRAGMA` was not - /// finalized early enough, leaving reader connection in a broken - /// state after reopening the database, when `config` table - /// existed and `PRAGMA` returned non-empty result. - /// - /// Statements were not finalized due to a bug in sqlx: - /// - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_db_reopen() -> Result<()> { - use tempfile::tempdir; - - // The context is used only for logging. - let t = TestContext::new().await; - - // Create a separate empty database for testing. - let dir = tempdir()?; - let dbfile = dir.path().join("testdb.sqlite"); - let sql = Sql::new(dbfile); - - // Create database with all the tables. - sql.open(&t, "".to_string()).await.unwrap(); - sql.close().await; - - // Reopen the database - sql.open(&t, "".to_string()).await?; - sql.execute( - "INSERT INTO config (keyname, value) VALUES (?, ?);", - ("foo", "bar"), - ) - .await?; - - let value: Option = sql - .query_get_value("SELECT value FROM config WHERE keyname=?;", ("foo",)) - .await?; - assert_eq!(value.unwrap(), "bar"); - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_migration_flags() -> Result<()> { - let t = TestContext::new().await; - t.evtracker.get_info_contains("Opened database").await; - - // as migrations::run() was already executed on context creation, - // another call should not result in any action needed. - // this test catches some bugs where dbversion was forgotten to be persisted. - let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) = - migrations::run(&t, &t.sql).await?; - assert!(!recalc_fingerprints); - assert!(!update_icons); - assert!(!disable_server_delete); - assert!(!recode_avatar); - - info!(&t, "test_migration_flags: XXX END MARKER"); - - loop { - let evt = t - .evtracker - .get_matching(|evt| matches!(evt, EventType::Info(_))) - .await; - match evt { - EventType::Info(msg) => { - assert!( - !msg.contains("[migration]"), - "Migrations were run twice, you probably forgot to update the db version" - ); - if msg.contains("test_migration_flags: XXX END MARKER") { - break; - } - } - _ => unreachable!(), - } - } - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_check_passphrase() -> Result<()> { - use tempfile::tempdir; - - // The context is used only for logging. - let t = TestContext::new().await; - - // Create a separate empty database for testing. - let dir = tempdir()?; - let dbfile = dir.path().join("testdb.sqlite"); - let sql = Sql::new(dbfile.clone()); - - sql.check_passphrase("foo".to_string()).await?; - sql.open(&t, "foo".to_string()) - .await - .context("failed to open the database first time")?; - sql.close().await; - - // Reopen the database - let sql = Sql::new(dbfile); - - // Test that we can't open encrypted database without a passphrase. - assert!(sql.open(&t, "".to_string()).await.is_err()); - - // Now open the database with passpharse, it should succeed. - sql.check_passphrase("foo".to_string()).await?; - sql.open(&t, "foo".to_string()) - .await - .context("failed to open the database second time")?; - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_sql_change_passphrase() -> Result<()> { - use tempfile::tempdir; - - // The context is used only for logging. - let t = TestContext::new().await; - - // Create a separate empty database for testing. - let dir = tempdir()?; - let dbfile = dir.path().join("testdb.sqlite"); - let sql = Sql::new(dbfile.clone()); - - sql.open(&t, "foo".to_string()) - .await - .context("failed to open the database first time")?; - sql.close().await; - - // Change the passphrase from "foo" to "bar". - let sql = Sql::new(dbfile.clone()); - sql.open(&t, "foo".to_string()) - .await - .context("failed to open the database second time")?; - sql.change_passphrase("bar".to_string()) - .await - .context("failed to change passphrase")?; - - // Test that at least two connections are still working. - // This ensures that not only the connection which changed the password is working, - // but other connections as well. - { - let lock = sql.pool.read().await; - let pool = lock.as_ref().unwrap(); - let query_only = true; - let conn1 = pool.get(query_only).await?; - let conn2 = pool.get(query_only).await?; - conn1 - .query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(())) - .unwrap(); - conn2 - .query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(())) - .unwrap(); - } - - sql.close().await; - - let sql = Sql::new(dbfile); - - // Test that old passphrase is not working. - assert!(sql.open(&t, "foo".to_string()).await.is_err()); - - // Open the database with the new passphrase. - sql.check_passphrase("bar".to_string()).await?; - sql.open(&t, "bar".to_string()) - .await - .context("failed to open the database third time")?; - sql.close().await; - - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_query_only() -> Result<()> { - let t = TestContext::new().await; - - // `query_row` does not acquire write lock - // and operates on read-only connection. - // Using it to `INSERT` should fail. - let res = t - .sql - .query_row( - "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 1", - ("xyz", "ijk"), - |row| { - let res: u32 = row.get(0)?; - Ok(res) - }, - ) - .await; - assert!(res.is_err()); - - // If you want to `INSERT` and get value via `RETURNING`, - // use `call_write` or `transaction`. - - let res: Result = t - .sql - .call_write(|conn| { - let val = conn.query_row( - "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 2", - ("foo", "bar"), - |row| { - let res: u32 = row.get(0)?; - Ok(res) - }, - )?; - Ok(val) - }) - .await; - assert_eq!(res.unwrap(), 2); - - let res = t - .sql - .transaction(|t| { - let val = t.query_row( - "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 3", - ("abc", "def"), - |row| { - let res: u32 = row.get(0)?; - Ok(res) - }, - )?; - Ok(val) - }) - .await; - assert_eq!(res.unwrap(), 3); - - Ok(()) - } - - /// Tests that incremental_vacuum does not fail. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_incremental_vacuum() -> Result<()> { - let t = TestContext::new().await; - - incremental_vacuum(&t).await?; - - Ok(()) - } -} +mod sql_tests; diff --git a/src/sql/sql_tests.rs b/src/sql/sql_tests.rs new file mode 100644 index 000000000..265c9acf1 --- /dev/null +++ b/src/sql/sql_tests.rs @@ -0,0 +1,372 @@ +use super::*; +use crate::{test_utils::TestContext, EventType}; + +#[test] +fn test_maybe_add_file() { + let mut files = Default::default(); + maybe_add_file(&mut files, "$BLOBDIR/hello"); + maybe_add_file(&mut files, "$BLOBDIR/world.txt"); + maybe_add_file(&mut files, "world2.txt"); + maybe_add_file(&mut files, "$BLOBDIR"); + + assert!(files.contains("hello")); + assert!(files.contains("world.txt")); + assert!(!files.contains("world2.txt")); + assert!(!files.contains("$BLOBDIR")); +} + +#[test] +fn test_is_file_in_use() { + let mut files = Default::default(); + maybe_add_file(&mut files, "$BLOBDIR/hello"); + maybe_add_file(&mut files, "$BLOBDIR/world.txt"); + maybe_add_file(&mut files, "world2.txt"); + + assert!(is_file_in_use(&files, None, "hello")); + assert!(!is_file_in_use(&files, Some(".txt"), "hello")); + assert!(is_file_in_use(&files, Some("-suffix"), "world.txt-suffix")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_table_exists() { + let t = TestContext::new().await; + assert!(t.ctx.sql.table_exists("msgs").await.unwrap()); + assert!(!t.ctx.sql.table_exists("foobar").await.unwrap()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_col_exists() { + let t = TestContext::new().await; + assert!(t.ctx.sql.col_exists("msgs", "mime_modified").await.unwrap()); + assert!(!t.ctx.sql.col_exists("msgs", "foobar").await.unwrap()); + assert!(!t.ctx.sql.col_exists("foobar", "foobar").await.unwrap()); +} + +/// Tests that auto_vacuum is enabled for new databases. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_auto_vacuum() -> Result<()> { + let t = TestContext::new().await; + + let query_only = true; + let auto_vacuum = t + .sql + .call(query_only, |conn| { + let auto_vacuum = conn.pragma_query_value(None, "auto_vacuum", |row| { + let auto_vacuum: i32 = row.get(0)?; + Ok(auto_vacuum) + })?; + Ok(auto_vacuum) + }) + .await?; + + // auto_vacuum=2 is the same as auto_vacuum=INCREMENTAL + assert_eq!(auto_vacuum, 2); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_housekeeping_db_closed() { + let t = TestContext::new().await; + + let avatar_src = t.dir.path().join("avatar.png"); + let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png"); + tokio::fs::write(&avatar_src, avatar_bytes).await.unwrap(); + t.set_config(Config::Selfavatar, Some(avatar_src.to_str().unwrap())) + .await + .unwrap(); + + let event_source = t.get_event_emitter(); + + let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); + assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]); + + t.sql.close().await; + housekeeping(&t).await.unwrap(); // housekeeping should emit warnings but not fail + t.sql.open(&t, "".to_string()).await.unwrap(); + + let a = t.get_config(Config::Selfavatar).await.unwrap().unwrap(); + assert_eq!(avatar_bytes, &tokio::fs::read(&a).await.unwrap()[..]); + + while let Ok(event) = event_source.try_recv() { + match event.typ { + EventType::Info(s) => assert!( + !s.contains("Keeping new unreferenced file"), + "File {s} was almost deleted, only reason it was kept is that it was created recently (as the tests don't run for a long time)" + ), + EventType::Error(s) => panic!("{}", s), + _ => {} + } + } +} + +/// Regression test for a bug where housekeeping deleted drafts since their +/// `hidden` flag is set. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_housekeeping_dont_delete_drafts() { + let t = TestContext::new_alice().await; + + let chat = t.create_chat_with_contact("bob", "bob@example.com").await; + let mut new_draft = Message::new_text("This is my draft".to_string()); + chat.id.set_draft(&t, Some(&mut new_draft)).await.unwrap(); + + housekeeping(&t).await.unwrap(); + + let loaded_draft = chat.id.get_draft(&t).await.unwrap(); + assert_eq!(loaded_draft.unwrap().text, "This is my draft"); +} + +/// Tests that `housekeeping` deletes the blobs backup dir which is created normally by +/// `imex::import_backup`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_housekeeping_delete_blobs_backup_dir() { + let t = TestContext::new_alice().await; + let dir = t.get_blobdir().join(BLOBS_BACKUP_NAME); + tokio::fs::create_dir(&dir).await.unwrap(); + tokio::fs::write(dir.join("f"), "").await.unwrap(); + housekeeping(&t).await.unwrap(); + tokio::fs::create_dir(&dir).await.unwrap(); +} + +/// Regression test. +/// +/// Previously the code checking for existence of `config` table +/// checked it with `PRAGMA table_info("config")` but did not +/// drain `SqlitePool.fetch` result, only using the first row +/// returned. As a result, prepared statement for `PRAGMA` was not +/// finalized early enough, leaving reader connection in a broken +/// state after reopening the database, when `config` table +/// existed and `PRAGMA` returned non-empty result. +/// +/// Statements were not finalized due to a bug in sqlx: +/// +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_db_reopen() -> Result<()> { + use tempfile::tempdir; + + // The context is used only for logging. + let t = TestContext::new().await; + + // Create a separate empty database for testing. + let dir = tempdir()?; + let dbfile = dir.path().join("testdb.sqlite"); + let sql = Sql::new(dbfile); + + // Create database with all the tables. + sql.open(&t, "".to_string()).await.unwrap(); + sql.close().await; + + // Reopen the database + sql.open(&t, "".to_string()).await?; + sql.execute( + "INSERT INTO config (keyname, value) VALUES (?, ?);", + ("foo", "bar"), + ) + .await?; + + let value: Option = sql + .query_get_value("SELECT value FROM config WHERE keyname=?;", ("foo",)) + .await?; + assert_eq!(value.unwrap(), "bar"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_migration_flags() -> Result<()> { + let t = TestContext::new().await; + t.evtracker.get_info_contains("Opened database").await; + + // as migrations::run() was already executed on context creation, + // another call should not result in any action needed. + // this test catches some bugs where dbversion was forgotten to be persisted. + let (recalc_fingerprints, update_icons, disable_server_delete, recode_avatar) = + migrations::run(&t, &t.sql).await?; + assert!(!recalc_fingerprints); + assert!(!update_icons); + assert!(!disable_server_delete); + assert!(!recode_avatar); + + info!(&t, "test_migration_flags: XXX END MARKER"); + + loop { + let evt = t + .evtracker + .get_matching(|evt| matches!(evt, EventType::Info(_))) + .await; + match evt { + EventType::Info(msg) => { + assert!( + !msg.contains("[migration]"), + "Migrations were run twice, you probably forgot to update the db version" + ); + if msg.contains("test_migration_flags: XXX END MARKER") { + break; + } + } + _ => unreachable!(), + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_check_passphrase() -> Result<()> { + use tempfile::tempdir; + + // The context is used only for logging. + let t = TestContext::new().await; + + // Create a separate empty database for testing. + let dir = tempdir()?; + let dbfile = dir.path().join("testdb.sqlite"); + let sql = Sql::new(dbfile.clone()); + + sql.check_passphrase("foo".to_string()).await?; + sql.open(&t, "foo".to_string()) + .await + .context("failed to open the database first time")?; + sql.close().await; + + // Reopen the database + let sql = Sql::new(dbfile); + + // Test that we can't open encrypted database without a passphrase. + assert!(sql.open(&t, "".to_string()).await.is_err()); + + // Now open the database with passpharse, it should succeed. + sql.check_passphrase("foo".to_string()).await?; + sql.open(&t, "foo".to_string()) + .await + .context("failed to open the database second time")?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_sql_change_passphrase() -> Result<()> { + use tempfile::tempdir; + + // The context is used only for logging. + let t = TestContext::new().await; + + // Create a separate empty database for testing. + let dir = tempdir()?; + let dbfile = dir.path().join("testdb.sqlite"); + let sql = Sql::new(dbfile.clone()); + + sql.open(&t, "foo".to_string()) + .await + .context("failed to open the database first time")?; + sql.close().await; + + // Change the passphrase from "foo" to "bar". + let sql = Sql::new(dbfile.clone()); + sql.open(&t, "foo".to_string()) + .await + .context("failed to open the database second time")?; + sql.change_passphrase("bar".to_string()) + .await + .context("failed to change passphrase")?; + + // Test that at least two connections are still working. + // This ensures that not only the connection which changed the password is working, + // but other connections as well. + { + let lock = sql.pool.read().await; + let pool = lock.as_ref().unwrap(); + let query_only = true; + let conn1 = pool.get(query_only).await?; + let conn2 = pool.get(query_only).await?; + conn1 + .query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(())) + .unwrap(); + conn2 + .query_row("SELECT count(*) FROM sqlite_master", [], |_row| Ok(())) + .unwrap(); + } + + sql.close().await; + + let sql = Sql::new(dbfile); + + // Test that old passphrase is not working. + assert!(sql.open(&t, "foo".to_string()).await.is_err()); + + // Open the database with the new passphrase. + sql.check_passphrase("bar".to_string()).await?; + sql.open(&t, "bar".to_string()) + .await + .context("failed to open the database third time")?; + sql.close().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_query_only() -> Result<()> { + let t = TestContext::new().await; + + // `query_row` does not acquire write lock + // and operates on read-only connection. + // Using it to `INSERT` should fail. + let res = t + .sql + .query_row( + "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 1", + ("xyz", "ijk"), + |row| { + let res: u32 = row.get(0)?; + Ok(res) + }, + ) + .await; + assert!(res.is_err()); + + // If you want to `INSERT` and get value via `RETURNING`, + // use `call_write` or `transaction`. + + let res: Result = t + .sql + .call_write(|conn| { + let val = conn.query_row( + "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 2", + ("foo", "bar"), + |row| { + let res: u32 = row.get(0)?; + Ok(res) + }, + )?; + Ok(val) + }) + .await; + assert_eq!(res.unwrap(), 2); + + let res = t + .sql + .transaction(|t| { + let val = t.query_row( + "INSERT INTO config (keyname, value) VALUES (?, ?) RETURNING 3", + ("abc", "def"), + |row| { + let res: u32 = row.get(0)?; + Ok(res) + }, + )?; + Ok(val) + }) + .await; + assert_eq!(res.unwrap(), 3); + + Ok(()) +} + +/// Tests that incremental_vacuum does not fail. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_incremental_vacuum() -> Result<()> { + let t = TestContext::new().await; + + incremental_vacuum(&t).await?; + + Ok(()) +} diff --git a/src/stock_str.rs b/src/stock_str.rs index d9e5afe46..98828aa49 100644 --- a/src/stock_str.rs +++ b/src/stock_str.rs @@ -1439,187 +1439,4 @@ impl Accounts { } #[cfg(test)] -mod tests { - use num_traits::ToPrimitive; - - use super::*; - use crate::chat::delete_and_reset_all_device_msgs; - use crate::chatlist::Chatlist; - use crate::test_utils::TestContext; - - #[test] - fn test_enum_mapping() { - assert_eq!(StockMessage::NoMessages.to_usize().unwrap(), 1); - assert_eq!(StockMessage::SelfMsg.to_usize().unwrap(), 2); - } - - #[test] - fn test_fallback() { - assert_eq!(StockMessage::NoMessages.fallback(), "No messages."); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_stock_translation() { - let t = TestContext::new().await; - t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string()) - .await - .unwrap(); - assert_eq!(no_messages(&t).await, "xyz") - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_set_stock_translation_wrong_replacements() { - let t = TestContext::new().await; - assert!(t - .ctx - .set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string()) - .await - .is_err()); - assert!(t - .ctx - .set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string()) - .await - .is_err()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_str() { - let t = TestContext::new().await; - assert_eq!(no_messages(&t).await, "No messages."); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_string_repl_str() { - let t = TestContext::new().await; - let contact_id = Contact::create(&t.ctx, "Someone", "someone@example.org") - .await - .unwrap(); - let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap(); - // uses %1$s substitution - assert_eq!( - contact_verified(&t, &contact).await, - "Someone (someone@example.org) verified." - ); - // We have no string using %1$d to test... - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_system_msg_simple() { - let t = TestContext::new().await; - assert_eq!( - msg_location_enabled(&t).await, - "Location streaming enabled." - ) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_system_msg_add_member_by_me() { - let t = TestContext::new().await; - assert_eq!( - msg_add_member_remote(&t, "alice@example.org").await, - "I added member alice@example.org." - ); - assert_eq!( - msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, - "You added member alice@example.org." - ) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_system_msg_add_member_by_me_with_displayname() { - let t = TestContext::new().await; - Contact::create(&t, "Alice", "alice@example.org") - .await - .expect("failed to create contact"); - assert_eq!( - msg_add_member_remote(&t, "alice@example.org").await, - "I added member alice@example.org." - ); - assert_eq!( - msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, - "You added member Alice (alice@example.org)." - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_stock_system_msg_add_member_by_other_with_displayname() { - let t = TestContext::new().await; - let contact_id = { - Contact::create(&t, "Alice", "alice@example.org") - .await - .expect("Failed to create contact Alice"); - Contact::create(&t, "Bob", "bob@example.com") - .await - .expect("failed to create bob") - }; - assert_eq!( - msg_add_member_local(&t, "alice@example.org", contact_id,).await, - "Member Alice (alice@example.org) added by Bob (bob@example.com)." - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_quota_exceeding_stock_str() -> Result<()> { - let t = TestContext::new().await; - let str = quota_exceeding(&t, 81).await; - assert!(str.contains("81% ")); - assert!(str.contains("100% ")); - assert!(!str.contains("%%")); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_partial_download_msg_body() -> Result<()> { - let t = TestContext::new().await; - let str = partial_download_msg_body(&t, 1024 * 1024).await; - assert_eq!(str, "1 MiB message"); - Ok(()) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn test_update_device_chats() { - let t = TestContext::new().await; - t.update_device_chats().await.ok(); - let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); - assert_eq!(chats.len(), 2); - - let chat0 = Chat::load_from_db(&t, chats.get_chat_id(0).unwrap()) - .await - .unwrap(); - let (self_talk_id, device_chat_id) = if chat0.is_self_talk() { - (chats.get_chat_id(0).unwrap(), chats.get_chat_id(1).unwrap()) - } else { - (chats.get_chat_id(1).unwrap(), chats.get_chat_id(0).unwrap()) - }; - - // delete self-talk first; this adds a message to device-chat about how self-talk can be restored - let device_chat_msgs_before = chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(); - self_talk_id.delete(&t).await.ok(); - assert_eq!( - chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(), - device_chat_msgs_before + 1 - ); - - // delete device chat - device_chat_id.delete(&t).await.ok(); - - // check, that the chatlist is empty - let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); - assert_eq!(chats.len(), 0); - - // a subsequent call to update_device_chats() must not re-add manually deleted messages or chats - t.update_device_chats().await.unwrap(); - let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); - assert_eq!(chats.len(), 0); - - // Reset all device messages. This normally happens due to account export and import. - // Check that update_device_chats() does not add welcome message for imported account. - delete_and_reset_all_device_msgs(&t).await.unwrap(); - let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); - assert_eq!(chats.len(), 0); - - t.update_device_chats().await.unwrap(); - let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); - assert_eq!(chats.len(), 0); - } -} +mod stock_str_tests; diff --git a/src/stock_str/stock_str_tests.rs b/src/stock_str/stock_str_tests.rs new file mode 100644 index 000000000..f63d4673d --- /dev/null +++ b/src/stock_str/stock_str_tests.rs @@ -0,0 +1,182 @@ +use num_traits::ToPrimitive; + +use super::*; +use crate::chat::delete_and_reset_all_device_msgs; +use crate::chatlist::Chatlist; +use crate::test_utils::TestContext; + +#[test] +fn test_enum_mapping() { + assert_eq!(StockMessage::NoMessages.to_usize().unwrap(), 1); + assert_eq!(StockMessage::SelfMsg.to_usize().unwrap(), 2); +} + +#[test] +fn test_fallback() { + assert_eq!(StockMessage::NoMessages.fallback(), "No messages."); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_stock_translation() { + let t = TestContext::new().await; + t.set_stock_translation(StockMessage::NoMessages, "xyz".to_string()) + .await + .unwrap(); + assert_eq!(no_messages(&t).await, "xyz") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_set_stock_translation_wrong_replacements() { + let t = TestContext::new().await; + assert!(t + .ctx + .set_stock_translation(StockMessage::NoMessages, "xyz %1$s ".to_string()) + .await + .is_err()); + assert!(t + .ctx + .set_stock_translation(StockMessage::NoMessages, "xyz %2$s ".to_string()) + .await + .is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_str() { + let t = TestContext::new().await; + assert_eq!(no_messages(&t).await, "No messages."); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_string_repl_str() { + let t = TestContext::new().await; + let contact_id = Contact::create(&t.ctx, "Someone", "someone@example.org") + .await + .unwrap(); + let contact = Contact::get_by_id(&t.ctx, contact_id).await.unwrap(); + // uses %1$s substitution + assert_eq!( + contact_verified(&t, &contact).await, + "Someone (someone@example.org) verified." + ); + // We have no string using %1$d to test... +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_system_msg_simple() { + let t = TestContext::new().await; + assert_eq!( + msg_location_enabled(&t).await, + "Location streaming enabled." + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_system_msg_add_member_by_me() { + let t = TestContext::new().await; + assert_eq!( + msg_add_member_remote(&t, "alice@example.org").await, + "I added member alice@example.org." + ); + assert_eq!( + msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, + "You added member alice@example.org." + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_system_msg_add_member_by_me_with_displayname() { + let t = TestContext::new().await; + Contact::create(&t, "Alice", "alice@example.org") + .await + .expect("failed to create contact"); + assert_eq!( + msg_add_member_remote(&t, "alice@example.org").await, + "I added member alice@example.org." + ); + assert_eq!( + msg_add_member_local(&t, "alice@example.org", ContactId::SELF).await, + "You added member Alice (alice@example.org)." + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stock_system_msg_add_member_by_other_with_displayname() { + let t = TestContext::new().await; + let contact_id = { + Contact::create(&t, "Alice", "alice@example.org") + .await + .expect("Failed to create contact Alice"); + Contact::create(&t, "Bob", "bob@example.com") + .await + .expect("failed to create bob") + }; + assert_eq!( + msg_add_member_local(&t, "alice@example.org", contact_id,).await, + "Member Alice (alice@example.org) added by Bob (bob@example.com)." + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_quota_exceeding_stock_str() -> Result<()> { + let t = TestContext::new().await; + let str = quota_exceeding(&t, 81).await; + assert!(str.contains("81% ")); + assert!(str.contains("100% ")); + assert!(!str.contains("%%")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_partial_download_msg_body() -> Result<()> { + let t = TestContext::new().await; + let str = partial_download_msg_body(&t, 1024 * 1024).await; + assert_eq!(str, "1 MiB message"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_update_device_chats() { + let t = TestContext::new().await; + t.update_device_chats().await.ok(); + let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); + assert_eq!(chats.len(), 2); + + let chat0 = Chat::load_from_db(&t, chats.get_chat_id(0).unwrap()) + .await + .unwrap(); + let (self_talk_id, device_chat_id) = if chat0.is_self_talk() { + (chats.get_chat_id(0).unwrap(), chats.get_chat_id(1).unwrap()) + } else { + (chats.get_chat_id(1).unwrap(), chats.get_chat_id(0).unwrap()) + }; + + // delete self-talk first; this adds a message to device-chat about how self-talk can be restored + let device_chat_msgs_before = chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(); + self_talk_id.delete(&t).await.ok(); + assert_eq!( + chat::get_chat_msgs(&t, device_chat_id).await.unwrap().len(), + device_chat_msgs_before + 1 + ); + + // delete device chat + device_chat_id.delete(&t).await.ok(); + + // check, that the chatlist is empty + let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); + assert_eq!(chats.len(), 0); + + // a subsequent call to update_device_chats() must not re-add manually deleted messages or chats + t.update_device_chats().await.unwrap(); + let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); + assert_eq!(chats.len(), 0); + + // Reset all device messages. This normally happens due to account export and import. + // Check that update_device_chats() does not add welcome message for imported account. + delete_and_reset_all_device_msgs(&t).await.unwrap(); + let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); + assert_eq!(chats.len(), 0); + + t.update_device_chats().await.unwrap(); + let chats = Chatlist::try_load(&t, 0, None, None).await.unwrap(); + assert_eq!(chats.len(), 0); +} diff --git a/test-data/message/blockquote-tag.eml b/test-data/message/blockquote-tag.eml deleted file mode 100644 index 6943ffcb4..000000000 --- a/test-data/message/blockquote-tag.eml +++ /dev/null @@ -1,47 +0,0 @@ -Return-Path: -User-Agent: K-9 Mail for Android -In-Reply-To: -MIME-Version: 1.0 -Content-Type: multipart/alternative; boundary="----MLV7YOLJ7ED4UZKNGQYQ63O0RJGHU0" -Content-Transfer-Encoding: 7bit -Subject: Re: Test -To: Alice -From: Bob -Message-ID: - -------MLV7YOLJ7ED4UZKNGQYQ63O0RJGHU0 -Content-Type: text/definitelynotplainthiswouldbetooeasy; - charset=utf-8 -Content-Transfer-Encoding: quoted-printable - -Hi Alice, - -some text. - -Am 21=2E Juni 2020 10:38:44 MESZ schrieb Alice : ->Dear Bob, -> ->let's meet -> ->Alice - ---=20 -Diese Nachricht wurde von meinem Android-Ger=C3=A4t mit K-9 Mail gesendet= -=2E -------MLV7YOLJ7ED4UZKNGQYQ63O0RJGHU0 -Content-Type: text/html; - charset=utf-8 -Content-Transfer-Encoding: quoted-printable - -Hi Alice,

some text.

-
Am 21=2E Juni 2020 10:38:44 M= -ESZ schrieb Alice <jonathanschmiederer@gmx=2Ede>: -
Sehr geehrte/r Frau/Herr Brenner,

ich habe in= - meinen JuFo-Unterlagen den angeh=C3=A4ngten Gutschein gefunden=2E
Ist e= -s noch m=C3=B6glich, diesen einzul=C3=B6sen?

Mit freundlichen Gr=C3= -=BC=C3=9Fen
Alice

--
= -Diese Nachricht wurde von meinem Android-Ger=C3=A4t mit K-9 Mail gesendet= -=2E -------MLV7YOLJ7ED4UZKNGQYQ63O0RJGHU0--