fix(sync): upsert tunnel profiles during selected restore

This commit is contained in:
Rendegou
2026-10-02 18:02:42 +08:00
committed by GitHub
parent a685056855
commit ca51d200be
2 changed files with 242 additions and 2 deletions
@@ -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();
+6 -2
View File
@@ -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,