mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sync): upsert tunnel profiles during selected restore
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user