mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 19:24:08 +00:00
233 lines
8.6 KiB
Rust
233 lines
8.6 KiB
Rust
use super::{init_tracing, Tester};
|
|
use prost::Message as _;
|
|
use rust_lib_twonly::api::messages::outgoing::send_c2c_message_to_contact;
|
|
use rust_lib_twonly::api::proto::client::{self as proto, encrypted_content};
|
|
use rust_lib_twonly::bridge::api::ApiConnectionState;
|
|
use rust_lib_twonly::database::app::tables::Group;
|
|
use rust_lib_twonly::services::contacts::ContactService;
|
|
use rust_lib_twonly::services::mediafiles::MediaFileService;
|
|
use rust_lib_twonly::services::messages::MessageService;
|
|
|
|
async fn create_authenticated_tester() -> anyhow::Result<Tester> {
|
|
let mut tester = Tester::new().await?;
|
|
tester.wait_until(ApiConnectionState::Connected).await?;
|
|
tester.register_and_authenticate().await?;
|
|
tester.wait_until(ApiConnectionState::Authenticated).await?;
|
|
Ok(tester)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_media_lifecycle_actions_and_reupload() -> anyhow::Result<()> {
|
|
init_tracing();
|
|
|
|
let tester_a = create_authenticated_tester().await?;
|
|
let tester_b = create_authenticated_tester().await?;
|
|
|
|
// Connect contacts A and B
|
|
ContactService::new(&tester_a.context)
|
|
.request_by_username(tester_b.username.clone(), true)
|
|
.await?;
|
|
tester_b
|
|
.wait_for_contact_state(tester_a.user_id, false, true)
|
|
.await?;
|
|
ContactService::new(&tester_b.context)
|
|
.accept_request(tester_a.user_id, true)
|
|
.await?;
|
|
tester_a
|
|
.wait_for_contact_state(tester_b.user_id, true, false)
|
|
.await?;
|
|
|
|
let group_id = Group::direct_chat_id(tester_a.user_id, tester_b.user_id);
|
|
let sender_message_id = uuid::Uuid::new_v4().to_string();
|
|
let download_token = vec![1u8; 32];
|
|
let encryption_key = vec![2u8; 32];
|
|
let encryption_mac = vec![3u8; 16];
|
|
let encryption_nonce = vec![4u8; 12];
|
|
let timestamp = chrono::Utc::now().timestamp_millis();
|
|
|
|
// 1. Tester A inserts local message and sends an encrypted media message to Tester B
|
|
let local_media_id = uuid::Uuid::new_v4().to_string();
|
|
{
|
|
let db_a = tester_a.context.app_db.read().await.clone();
|
|
// Give the media real key material and a plaintext file so the reupload
|
|
// below exercises encryption and envelope creation rather than the
|
|
// "source is gone" shortcut.
|
|
sqlx::query!(
|
|
r#"INSERT INTO media_files
|
|
(media_id, type, download_state, upload_state, encryption_key, encryption_nonce)
|
|
VALUES (?, 'image', 'ready', 'uploaded', ?, ?)"#,
|
|
local_media_id,
|
|
encryption_key,
|
|
encryption_nonce,
|
|
)
|
|
.execute(&db_a.pool)
|
|
.await?;
|
|
sqlx::query!(
|
|
"INSERT INTO messages(group_id, message_id, type, media_id, created_at) VALUES (?, ?, 'media', ?, CAST(strftime('%s','now') AS INTEGER))",
|
|
group_id,
|
|
sender_message_id,
|
|
local_media_id,
|
|
)
|
|
.execute(&db_a.pool)
|
|
.await?;
|
|
}
|
|
|
|
// Keep the plaintext on disk for the whole test: the sender needs it to
|
|
// re-encrypt when a recipient later reports a decryption error.
|
|
let plaintext_path = std::path::PathBuf::from(&tester_a.context.config.data_dir)
|
|
.join("mediafiles/tmp")
|
|
.join(format!("{local_media_id}.webp"));
|
|
std::fs::create_dir_all(plaintext_path.parent().expect("tmp directory"))?;
|
|
std::fs::write(&plaintext_path, b"plaintext media bytes")?;
|
|
|
|
let media_content = proto::EncryptedContent {
|
|
group_id: Some(group_id.clone()),
|
|
media: Some(encrypted_content::Media {
|
|
sender_message_id: sender_message_id.clone(),
|
|
r#type: encrypted_content::media::Type::Image as i32,
|
|
download_token: Some(download_token.clone()),
|
|
encryption_key: Some(encryption_key.clone()),
|
|
encryption_mac: Some(encryption_mac.clone()),
|
|
encryption_nonce: Some(encryption_nonce.clone()),
|
|
timestamp,
|
|
requires_authentication: false,
|
|
display_limit_in_milliseconds: Some(0),
|
|
additional_message_data: None,
|
|
quote_message_id: None,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
send_c2c_message_to_contact()
|
|
.ctx(&tester_a.context)
|
|
.contact_id(tester_b.user_id)
|
|
.encrypted_content(media_content.encode_to_vec())
|
|
.call()
|
|
.await?;
|
|
|
|
// Wait until Tester B has the message and media record in DB
|
|
let mut media_id_on_b = String::new();
|
|
for _ in 0..100 {
|
|
let db_b = tester_b.context.app_db.read().await.clone();
|
|
let row = sqlx::query!(
|
|
"SELECT media_id FROM messages WHERE message_id = ?",
|
|
sender_message_id
|
|
)
|
|
.fetch_optional(&db_b.pool)
|
|
.await?;
|
|
if let Some(r) = row {
|
|
if let Some(mid) = r.media_id {
|
|
media_id_on_b = mid;
|
|
break;
|
|
}
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
}
|
|
assert!(
|
|
!media_id_on_b.is_empty(),
|
|
"media_id must be generated on receiver"
|
|
);
|
|
tester_b
|
|
.wait_for_media_download_token(&media_id_on_b, &download_token)
|
|
.await?;
|
|
|
|
// 2. Tester B marks media as stored
|
|
let stored_update = proto::EncryptedContent {
|
|
media_update: Some(encrypted_content::MediaUpdate {
|
|
r#type: encrypted_content::media_update::Type::Stored as i32,
|
|
target_message_id: sender_message_id.clone(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
send_c2c_message_to_contact()
|
|
.ctx(&tester_b.context)
|
|
.contact_id(tester_a.user_id)
|
|
.encrypted_content(stored_update.encode_to_vec())
|
|
.call()
|
|
.await?;
|
|
|
|
tester_a.wait_for_media_stored(&sender_message_id).await?;
|
|
|
|
// 3. Tester B marks media as reopened
|
|
let reopened_update = proto::EncryptedContent {
|
|
media_update: Some(encrypted_content::MediaUpdate {
|
|
r#type: encrypted_content::media_update::Type::Reopened as i32,
|
|
target_message_id: sender_message_id.clone(),
|
|
}),
|
|
..Default::default()
|
|
};
|
|
send_c2c_message_to_contact()
|
|
.ctx(&tester_b.context)
|
|
.contact_id(tester_a.user_id)
|
|
.encrypted_content(reopened_update.encode_to_vec())
|
|
.call()
|
|
.await?;
|
|
|
|
tester_a.wait_for_media_reopened(&sender_message_id).await?;
|
|
|
|
// 4. Tester B simulates decryption error & requests reupload
|
|
MediaFileService::new(&tester_b.context)
|
|
.request_reupload(&media_id_on_b)
|
|
.await?;
|
|
|
|
// Rust owns the reupload: it records who asked, re-encrypts, and reserves a
|
|
// direct-media slot. Scheduling the transfer needs a mobile background
|
|
// uploader, so on this host preparation stops there and the media stays
|
|
// queued instead of being lost.
|
|
tester_a
|
|
.wait_for_media_reupload_requested(&local_media_id, tester_b.user_id)
|
|
.await?;
|
|
tester_a
|
|
.wait_for_media_upload_state(&local_media_id, "preprocessing")
|
|
.await?;
|
|
|
|
tester_a
|
|
.wait_for_media_encryption_mac(&local_media_id)
|
|
.await?;
|
|
|
|
// The slot was reserved but never handed to a native uploader, so no job may
|
|
// be left behind and the slot has to become reusable again. A recipient can
|
|
// ask more than once, so wait for preparation to quiesce first.
|
|
tester_a.wait_for_no_pending_upload_jobs().await?;
|
|
|
|
// 5. Tester A responds with MediaType::Reupload
|
|
let reupload_content = proto::EncryptedContent {
|
|
group_id: Some(group_id.clone()),
|
|
media: Some(encrypted_content::Media {
|
|
sender_message_id: sender_message_id.clone(),
|
|
r#type: encrypted_content::media::Type::Reupload as i32,
|
|
download_token: Some(vec![9u8; 32]),
|
|
encryption_key: Some(vec![8u8; 32]),
|
|
encryption_mac: Some(vec![7u8; 16]),
|
|
encryption_nonce: Some(vec![6u8; 12]),
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
requires_authentication: false,
|
|
display_limit_in_milliseconds: Some(0),
|
|
additional_message_data: None,
|
|
quote_message_id: None,
|
|
}),
|
|
..Default::default()
|
|
};
|
|
send_c2c_message_to_contact()
|
|
.ctx(&tester_a.context)
|
|
.contact_id(tester_b.user_id)
|
|
.encrypted_content(reupload_content.encode_to_vec())
|
|
.call()
|
|
.await?;
|
|
|
|
// The reupload replaces the capability B holds for this attachment.
|
|
tester_b
|
|
.wait_for_media_download_token(&media_id_on_b, &[9u8; 32])
|
|
.await?;
|
|
|
|
// 6. Tester A deletes the message
|
|
MessageService::new(&tester_a.context)
|
|
.delete_message(group_id.clone(), sender_message_id.clone())
|
|
.await?;
|
|
|
|
tester_b
|
|
.wait_for_message_deleted(&sender_message_id)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|