From 5c79572c4da2a98a8cda36a655d7494280543f0d Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:11:24 +0100 Subject: [PATCH] matrix: fix empty bridge state json (#592) * matrix: fix empty bridge state json * matrix: fix tests --- matrix/src/main.rs | 32 +++++++++++++++-- matrix/src/matrix.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/matrix/src/main.rs b/matrix/src/main.rs index 06c4170..0a9a397 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -38,6 +38,10 @@ impl BridgeState { return Ok(Self::default()); } let data = fs::read_to_string(path)?; + // Treat empty/whitespace-only files as a fresh state. + if data.trim().is_empty() { + return Ok(Self::default()); + } let s: Self = serde_json::from_str(&data)?; Ok(s) } @@ -199,6 +203,7 @@ async fn handle_message( // Ensure puppet exists & has display name matrix.ensure_user_registered(&localpart).await?; + matrix.ensure_user_joined_room(&user_id).await?; matrix.set_display_name(&user_id, &node.long_name).await?; // Format the bridged message @@ -331,6 +336,19 @@ mod tests { assert_eq!(state.last_checked_at, None); } + #[test] + fn bridge_state_load_empty_file() { + let tmp_dir = tempfile::tempdir().unwrap(); + let file_path = tmp_dir.path().join("empty.json"); + let path_str = file_path.to_str().unwrap(); + + fs::write(path_str, "").unwrap(); + + let state = BridgeState::load(path_str).unwrap(); + assert_eq!(state.last_message_id, None); + assert_eq!(state.last_checked_at, None); + } + #[test] fn update_checkpoint_requires_last_message_id() { let mut state = BridgeState { @@ -467,6 +485,8 @@ mod tests { let node_id = "abcd1234"; let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name); let encoded_user = urlencoding::encode(&user_id); + let room_id = matrix_cfg.room_id.clone(); + let encoded_room = urlencoding::encode(&room_id); let mock_get_node = server .mock("GET", "/api/nodes/abcd1234") @@ -481,6 +501,15 @@ mod tests { .with_status(200) .create(); + let mock_join = server + .mock( + "POST", + format!("/_matrix/client/v3/rooms/{}/join", encoded_room).as_str(), + ) + .match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str()) + .with_status(200) + .create(); + let mock_display_name = server .mock( "PUT", @@ -492,8 +521,6 @@ mod tests { let http_client = reqwest::Client::new(); let matrix_client = MatrixAppserviceClient::new(http_client.clone(), matrix_cfg); - let room_id = &matrix_client.cfg.room_id; - let encoded_room = urlencoding::encode(room_id); let txn_id = matrix_client .txn_counter .load(std::sync::atomic::Ordering::SeqCst); @@ -520,6 +547,7 @@ mod tests { assert!(result.is_ok()); mock_get_node.assert(); mock_register.assert(); + mock_join.assert(); mock_display_name.assert(); mock_send.assert(); diff --git a/matrix/src/matrix.rs b/matrix/src/matrix.rs index a44d26b..050e309 100644 --- a/matrix/src/matrix.rs +++ b/matrix/src/matrix.rs @@ -134,6 +134,37 @@ impl MatrixAppserviceClient { } } + /// Ensure the puppet user is joined to the configured room. + pub async fn ensure_user_joined_room(&self, user_id: &str) -> anyhow::Result<()> { + #[derive(Serialize)] + struct JoinReq {} + + let encoded_room = urlencoding::encode(&self.cfg.room_id); + let encoded_user = urlencoding::encode(user_id); + let url = format!( + "{}/_matrix/client/v3/rooms/{}/join?user_id={}&{}", + self.cfg.homeserver, + encoded_room, + encoded_user, + self.auth_query() + ); + + let resp = self.http.post(&url).json(&JoinReq {}).send().await?; + if resp.status().is_success() { + Ok(()) + } else { + let status = resp.status(); + let body_snip = resp.text().await.unwrap_or_default(); + Err(anyhow::anyhow!( + "Matrix join failed for {} in {} with status {} ({})", + user_id, + self.cfg.room_id, + status, + body_snip + )) + } + } + /// Send a plain text message into the configured room as puppet user_id. pub async fn send_text_message_as(&self, user_id: &str, body_text: &str) -> anyhow::Result<()> { #[derive(Serialize)] @@ -357,6 +388,58 @@ mod tests { assert!(result.is_ok()); } + #[tokio::test] + async fn test_ensure_user_joined_room_success() { + let mut server = mockito::Server::new_async().await; + let user_id = "@test:example.org"; + let room_id = "!roomid:example.org"; + let encoded_user = urlencoding::encode(user_id); + let encoded_room = urlencoding::encode(room_id); + let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room); + + let mock = server + .mock("POST", path.as_str()) + .match_query(query.as_str()) + .with_status(200) + .create(); + + let mut cfg = dummy_cfg(); + cfg.homeserver = server.url(); + cfg.room_id = room_id.to_string(); + let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg); + let result = client.ensure_user_joined_room(user_id).await; + + mock.assert(); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_ensure_user_joined_room_fail() { + let mut server = mockito::Server::new_async().await; + let user_id = "@test:example.org"; + let room_id = "!roomid:example.org"; + let encoded_user = urlencoding::encode(user_id); + let encoded_room = urlencoding::encode(room_id); + let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); + let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room); + + let mock = server + .mock("POST", path.as_str()) + .match_query(query.as_str()) + .with_status(403) + .create(); + + let mut cfg = dummy_cfg(); + cfg.homeserver = server.url(); + cfg.room_id = room_id.to_string(); + let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg); + let result = client.ensure_user_joined_room(user_id).await; + + mock.assert(); + assert!(result.is_err()); + } + #[tokio::test] async fn test_send_text_message_as_success() { let mut server = mockito::Server::new_async().await;