From ca51d200be45d4761dd0b779c81ed3bfb812d2d1 Mon Sep 17 00:00:00 2001 From: Rendegou <2739704861@qq.com> Date: Fri, 2 Oct 2026 18:02:42 +0800 Subject: [PATCH] fix(sync): upsert tunnel profiles during selected restore --- crates/dbx-core/src/persistence/cloud_sync.rs | 236 ++++++++++++++++++ crates/dbx-core/src/persistence/storage.rs | 8 +- 2 files changed, 242 insertions(+), 2 deletions(-) diff --git a/crates/dbx-core/src/persistence/cloud_sync.rs b/crates/dbx-core/src/persistence/cloud_sync.rs index f15090eaba..f2b1b959d4 100644 --- a/crates/dbx-core/src/persistence/cloud_sync.rs +++ b/crates/dbx-core/src/persistence/cloud_sync.rs @@ -5163,6 +5163,242 @@ mod tests { assert_eq!(source.get_secret("nacos", NACOS_RNACOS_CONSOLE_PASSWORD_KEY).await.unwrap(), None); } + fn ssh_profile_defaults() -> SshTunnelConfig { + serde_json::from_value(serde_json::json!({})).unwrap() + } + + #[tokio::test] + async fn selected_tunnel_restore_updates_existing_id_and_is_repeatable() { + let directory = tempfile::tempdir().unwrap(); + let storage = crate::persistence::test_storage::open(&directory.path().join("dbx.db")).await.unwrap(); + let profile = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + name: "Bastion".to_string(), + host: "bastion.example.com".to_string(), + ..ssh_profile_defaults() + }); + storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + assert!(snapshot.connections.is_empty()); + let selection = super::SyncSelection { + connections: Some(vec![]), + tunnel_profiles: Some(vec!["same-id".to_string()]), + ..Default::default() + }; + + for _ in 0..2 { + super::apply_sync_snapshot_with_selection( + &storage, + &snapshot, + ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false }, + Some(&selection), + None, + ) + .await + .unwrap(); + assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![profile.clone()]); + } + } + + #[tokio::test] + async fn selected_tunnel_restore_preserves_unselected_profiles_and_local_paths() { + for restore_secrets in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let source = crate::persistence::test_storage::open(&directory.path().join("source.db")).await.unwrap(); + let target = crate::persistence::test_storage::open(&directory.path().join("target.db")).await.unwrap(); + let remote = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + name: "Remote bastion".to_string(), + host: "remote.example.com".to_string(), + password: "remote-secret".to_string(), + ..ssh_profile_defaults() + }); + let local = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + name: "Local bastion".to_string(), + password: "local-secret".to_string(), + key_path: "C:\\keys\\bastion.pem".to_string(), + ssh_agent_sock_path: "local-agent".to_string(), + ..ssh_profile_defaults() + }); + let untouched = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "local-only".to_string(), + password: "untouched-secret".to_string(), + ..ssh_profile_defaults() + }); + source.save_tunnel_profiles(std::slice::from_ref(&remote)).await.unwrap(); + target.save_tunnel_profiles(&[local, untouched.clone()]).await.unwrap(); + let mut snapshot = build_sync_snapshot(&source, "test-version", None, Some("sync-pass")).await.unwrap(); + snapshot.selection = Some(super::SyncSelection { + connections: Some(vec![]), + tunnel_profiles: Some(vec!["same-id".to_string()]), + include_secrets: restore_secrets, + ..Default::default() + }); + for _ in 0..2 { + apply_sync_snapshot( + &target, + &snapshot, + ApplySnapshotOptions { secrets_passphrase: Some("sync-pass"), restore_secrets }, + ) + .await + .unwrap(); + let profiles = target.load_tunnel_profiles().await.unwrap(); + assert_eq!(profiles.len(), 2); + assert!(profiles.contains(&untouched)); + let TransportLayerConfig::Ssh(restored) = profiles.iter().find(|p| p.id() == "same-id").unwrap() else { + panic!("expected SSH profile"); + }; + assert_eq!(restored.name, "Remote bastion"); + assert_eq!(restored.host, "remote.example.com"); + assert_eq!(restored.key_path, "C:\\keys\\bastion.pem"); + assert_eq!(restored.ssh_agent_sock_path, "local-agent"); + assert_eq!(restored.password, if restore_secrets { "remote-secret" } else { "local-secret" }); + } + } + } + + #[tokio::test] + async fn selected_tunnel_restore_after_webdav_upload_and_download_is_repeatable() { + let directory = tempfile::tempdir().unwrap(); + let storage = crate::persistence::test_storage::open(&directory.path().join("dbx.db")).await.unwrap(); + let profile = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + host: "bastion.example.com".to_string(), + ..ssh_profile_defaults() + }); + storage.save_tunnel_profiles(std::slice::from_ref(&profile)).await.unwrap(); + let snapshot = build_sync_snapshot(&storage, "test-version", None, None).await.unwrap(); + assert!(snapshot.connections.is_empty()); + let body = serde_json::to_string_pretty(&snapshot).unwrap(); + // Reuse the HTTP fixture, which captures complete request bodies. + let (endpoint, server) = spawn_gitlab_server(vec![String::new(), body.clone()]).await; + let client = WebDavClient::new(WebDavConfig { + endpoint: format!("{endpoint}/"), + username: None, + password: None, + remote_path: Some("snapshot.json".to_string()), + }); + client.put_snapshot(&snapshot).await.unwrap(); + let (downloaded, _) = client.get_snapshot().await.unwrap(); + let requests = server.await.unwrap(); + assert!(requests[0].starts_with("PUT /api/v4/snapshot.json ")); + assert_eq!(requests[0].split_once("\r\n\r\n").unwrap().1, body); + assert!(requests[1].starts_with("GET /api/v4/snapshot.json ")); + let selection = super::SyncSelection { + connections: Some(vec![]), + tunnel_profiles: Some(vec!["same-id".to_string()]), + ..Default::default() + }; + for _ in 0..2 { + super::apply_sync_snapshot_with_selection( + &storage, + &downloaded, + ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false }, + Some(&selection), + None, + ) + .await + .unwrap(); + assert_eq!(storage.load_tunnel_profiles().await.unwrap(), vec![profile.clone()]); + } + } + + #[tokio::test] + async fn selected_tunnel_restore_empty_selection_and_new_id_preserve_local_profiles() { + let directory = tempfile::tempdir().unwrap(); + let source = crate::persistence::test_storage::open(&directory.path().join("source.db")).await.unwrap(); + let target = crate::persistence::test_storage::open(&directory.path().join("target.db")).await.unwrap(); + let local = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "local-only".to_string(), + password: "local-secret".to_string(), + ..ssh_profile_defaults() + }); + let remote = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "new-id".to_string(), + host: "new.example.com".to_string(), + ..ssh_profile_defaults() + }); + target.save_tunnel_profiles(std::slice::from_ref(&local)).await.unwrap(); + source.save_tunnel_profiles(std::slice::from_ref(&remote)).await.unwrap(); + let snapshot = build_sync_snapshot(&source, "test-version", None, None).await.unwrap(); + let mut selection = + super::SyncSelection { connections: Some(vec![]), tunnel_profiles: Some(vec![]), ..Default::default() }; + for selected_ids in [vec![], vec!["new-id".to_string()]] { + selection.tunnel_profiles = Some(selected_ids); + super::apply_sync_snapshot_with_selection( + &target, + &snapshot, + ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false }, + Some(&selection), + None, + ) + .await + .unwrap(); + let profiles = target.load_tunnel_profiles().await.unwrap(); + assert!(profiles.contains(&local)); + assert_eq!(profiles.contains(&remote), !selection.tunnel_profiles.as_ref().unwrap().is_empty()); + } + // No selection means replacement rather than merge. + apply_sync_snapshot( + &target, + &snapshot, + ApplySnapshotOptions { secrets_passphrase: None, restore_secrets: false }, + ) + .await + .unwrap(); + assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![remote]); + assert_eq!(target.get_secret("tunnel_profile.local-only", "config").await.unwrap(), None); + } + + #[tokio::test] + async fn selected_tunnel_restore_rolls_back_metadata_and_secrets_on_later_failure() { + let directory = tempfile::tempdir().unwrap(); + let source = crate::persistence::test_storage::open(&directory.path().join("source.db")).await.unwrap(); + let target = crate::persistence::test_storage::open(&directory.path().join("target.db")).await.unwrap(); + let local = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + host: "local.example.com".to_string(), + password: "local-secret".to_string(), + ..ssh_profile_defaults() + }); + let remote = TransportLayerConfig::Ssh(SshTunnelConfig { + id: "same-id".to_string(), + host: "remote.example.com".to_string(), + password: "remote-secret".to_string(), + ..ssh_profile_defaults() + }); + target.save_tunnel_profiles(std::slice::from_ref(&local)).await.unwrap(); + source.save_tunnel_profiles(std::slice::from_ref(&remote)).await.unwrap(); + let mut snapshot = build_sync_snapshot(&source, "test-version", None, Some("sync-pass")).await.unwrap(); + snapshot.selection = Some(super::SyncSelection { + connections: Some(vec![]), + tunnel_profiles: Some(vec!["same-id".to_string()]), + include_secrets: true, + ..Default::default() + }); + let folder = crate::saved_sql::SavedSqlFolder { + id: "duplicate-folder".to_string(), + connection_id: String::new(), + parent_folder_id: None, + name: "test".to_string(), + order_index: 0, + created_at: String::new(), + updated_at: String::new(), + }; + // Saved SQL is written after tunnel metadata and secrets in the same transaction. + snapshot.saved_sql.folders = vec![folder.clone(), folder]; + let error = apply_sync_snapshot( + &target, + &snapshot, + ApplySnapshotOptions { secrets_passphrase: Some("sync-pass"), restore_secrets: true }, + ) + .await + .unwrap_err(); + assert!(error.contains("duplicate saved SQL folder id"), "unexpected failure: {error}"); + assert_eq!(target.load_tunnel_profiles().await.unwrap(), vec![local]); + } + #[tokio::test] async fn sync_snapshot_round_trips_tunnel_profiles() { let storage = crate::persistence::test_storage::open(&temp_db_path("tunnel-profiles-src")).await.unwrap(); diff --git a/crates/dbx-core/src/persistence/storage.rs b/crates/dbx-core/src/persistence/storage.rs index 1fefb5445d..1393222a32 100644 --- a/crates/dbx-core/src/persistence/storage.rs +++ b/crates/dbx-core/src/persistence/storage.rs @@ -8378,8 +8378,12 @@ fn apply_sync_tunnel_profiles_in_tx( let mut sanitized = profile.clone(); sanitized.scrub_secrets(); let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?; - tx.execute("INSERT INTO tunnel_profiles (id, config_json) VALUES (?1, ?2)", params![profile.id(), json]) - .map_err(|e| e.to_string())?; + tx.execute( + "INSERT INTO tunnel_profiles (id, config_json) VALUES (?1, ?2) \ + ON CONFLICT(id) DO UPDATE SET config_json = excluded.config_json", + params![profile.id(), json], + ) + .map_err(|e| e.to_string())?; if sanitized != profile { persist_secret_in_tx( tx,