Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 2m55s
Tauri auto-updater: - Signing pubkey in tauri.conf.json - tauri-plugin-updater initialized in lib.rs - CI workflow passes TAURI_SIGNING_PRIVATE_KEY env vars to cargo tauri build - CI generates update.json manifest with signature and uploads to packages/latest/update.json endpoint - Frontend checks for updates on startup via @tauri-apps/plugin-updater - Downloads, installs, and relaunches seamlessly - Settings → About button uses native updater too RDP vault credentials: - RDP connections now resolve credentials from vault via credentialId - Same path as SSH: list_credentials → find by ID → decrypt_password - Falls back to conn.options JSON if no vault credential linked - Fixes blank username in RDP connect Sidebar drag persist: - reorder_connections and reorder_groups Tauri commands - Batch-update sort_order in database on drop - Order survives app restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
747 lines
26 KiB
Rust
747 lines
26 KiB
Rust
//! Connection and connection-group CRUD service backed by SQLite.
|
|
//!
|
|
//! # Data model
|
|
//!
|
|
//! ```text
|
|
//! groups — hierarchical folders for organising connections
|
|
//! connections — individual SSH / RDP targets
|
|
//! ```
|
|
//!
|
|
//! Tags and options are stored as JSON strings in TEXT columns (matching the
|
|
//! schema in `001_initial.sql`). The service serialises/deserialises them
|
|
//! transparently so callers always deal with typed Rust values.
|
|
|
|
use rusqlite::params;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::db::Database;
|
|
|
|
// ── domain types ──────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct ConnectionGroup {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub parent_id: Option<i64>,
|
|
pub sort_order: i64,
|
|
pub icon: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ConnectionRecord {
|
|
pub id: i64,
|
|
pub name: String,
|
|
pub hostname: String,
|
|
pub port: i64,
|
|
pub protocol: String,
|
|
pub group_id: Option<i64>,
|
|
pub credential_id: Option<i64>,
|
|
pub color: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub notes: Option<String>,
|
|
pub options: String,
|
|
pub sort_order: i64,
|
|
pub last_connected: Option<String>,
|
|
}
|
|
|
|
// ── input types ───────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateConnectionInput {
|
|
pub name: String,
|
|
pub hostname: String,
|
|
pub port: i64,
|
|
pub protocol: String,
|
|
pub group_id: Option<i64>,
|
|
pub credential_id: Option<i64>,
|
|
pub color: Option<String>,
|
|
pub tags: Vec<String>,
|
|
pub notes: Option<String>,
|
|
/// Optional JSON object; defaults to `{}` when absent.
|
|
pub options: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct UpdateConnectionInput {
|
|
pub name: Option<String>,
|
|
pub hostname: Option<String>,
|
|
pub port: Option<i64>,
|
|
pub protocol: Option<String>,
|
|
pub group_id: Option<i64>,
|
|
pub credential_id: Option<i64>,
|
|
pub color: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub notes: Option<String>,
|
|
pub options: Option<String>,
|
|
}
|
|
|
|
// ── service ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Provides CRUD operations for connections and connection groups.
|
|
#[derive(Clone)]
|
|
pub struct ConnectionService {
|
|
db: Database,
|
|
}
|
|
|
|
impl ConnectionService {
|
|
pub fn new(db: Database) -> Self {
|
|
Self { db }
|
|
}
|
|
|
|
// ── groups ────────────────────────────────────────────────────────────────
|
|
|
|
/// Create a new connection group.
|
|
///
|
|
/// `sort_order` is automatically set to `(max_existing + 1)` so new groups
|
|
/// appear at the bottom of the list.
|
|
pub fn create_group(
|
|
&self,
|
|
name: &str,
|
|
parent_id: Option<i64>,
|
|
) -> Result<ConnectionGroup, String> {
|
|
let conn = self.db.conn();
|
|
|
|
// Determine next sort_order within the same parent level.
|
|
let sort_order: i64 = conn
|
|
.query_row(
|
|
"SELECT COALESCE(MAX(sort_order), 0) + 1 FROM groups WHERE parent_id IS ?1",
|
|
params![parent_id],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|e| format!("Failed to compute sort_order for group: {e}"))?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO groups (name, parent_id, sort_order) VALUES (?1, ?2, ?3)",
|
|
params![name, parent_id, sort_order],
|
|
)
|
|
.map_err(|e| format!("Failed to create group '{name}': {e}"))?;
|
|
|
|
let id = conn.last_insert_rowid();
|
|
|
|
Ok(ConnectionGroup {
|
|
id,
|
|
name: name.to_string(),
|
|
parent_id,
|
|
sort_order,
|
|
icon: None,
|
|
})
|
|
}
|
|
|
|
/// Return all groups ordered by `(parent_id NULLS FIRST, sort_order)`.
|
|
pub fn list_groups(&self) -> Result<Vec<ConnectionGroup>, String> {
|
|
let conn = self.db.conn();
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, name, parent_id, sort_order, icon
|
|
FROM groups
|
|
ORDER BY parent_id NULLS FIRST, sort_order ASC",
|
|
)
|
|
.map_err(|e| format!("Failed to prepare group list query: {e}"))?;
|
|
|
|
let groups = stmt
|
|
.query_map([], |row| {
|
|
Ok(ConnectionGroup {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
parent_id: row.get::<_, Option<i64>>(2)?,
|
|
sort_order: row.get(3)?,
|
|
icon: row.get::<_, Option<String>>(4)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("Failed to list groups: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to read group row: {e}"))?;
|
|
|
|
Ok(groups)
|
|
}
|
|
|
|
/// Delete a group by ID.
|
|
///
|
|
/// Any connections in the group have their `group_id` set to NULL (via the
|
|
/// `ON DELETE SET NULL` FK constraint in the schema). Child groups
|
|
/// similarly have their `parent_id` set to NULL.
|
|
pub fn delete_group(&self, id: i64) -> Result<(), String> {
|
|
let conn = self.db.conn();
|
|
let affected = conn
|
|
.execute("DELETE FROM groups WHERE id = ?1", params![id])
|
|
.map_err(|e| format!("Failed to delete group {id}: {e}"))?;
|
|
|
|
if affected == 0 {
|
|
return Err(format!("Group {id} not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Rename a group.
|
|
pub fn rename_group(&self, id: i64, name: &str) -> Result<(), String> {
|
|
let conn = self.db.conn();
|
|
let affected = conn
|
|
.execute(
|
|
"UPDATE groups SET name = ?1 WHERE id = ?2",
|
|
params![name, id],
|
|
)
|
|
.map_err(|e| format!("Failed to rename group {id}: {e}"))?;
|
|
|
|
if affected == 0 {
|
|
return Err(format!("Group {id} not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── connections ───────────────────────────────────────────────────────────
|
|
|
|
/// Create a new connection record.
|
|
pub fn create_connection(
|
|
&self,
|
|
input: CreateConnectionInput,
|
|
) -> Result<ConnectionRecord, String> {
|
|
let conn = self.db.conn();
|
|
|
|
// Serialise tags Vec → JSON array string.
|
|
let tags_json = serde_json::to_string(&input.tags)
|
|
.map_err(|e| format!("Failed to serialise tags: {e}"))?;
|
|
|
|
// Default options to `{}` when not supplied.
|
|
let options_json = input.options.unwrap_or_else(|| "{}".to_string());
|
|
|
|
// Validate options is at least parseable JSON (fail fast before insert).
|
|
serde_json::from_str::<serde_json::Value>(&options_json)
|
|
.map_err(|e| format!("options is not valid JSON: {e}"))?;
|
|
|
|
// sort_order = max + 1 within the same group (or global if no group).
|
|
let sort_order: i64 = conn
|
|
.query_row(
|
|
"SELECT COALESCE(MAX(sort_order), 0) + 1 FROM connections WHERE group_id IS ?1",
|
|
params![input.group_id],
|
|
|row| row.get(0),
|
|
)
|
|
.map_err(|e| format!("Failed to compute sort_order for connection: {e}"))?;
|
|
|
|
conn.execute(
|
|
"INSERT INTO connections
|
|
(name, hostname, port, protocol, group_id, credential_id,
|
|
color, tags, notes, options, sort_order)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
params![
|
|
input.name,
|
|
input.hostname,
|
|
input.port,
|
|
input.protocol,
|
|
input.group_id,
|
|
input.credential_id,
|
|
input.color,
|
|
tags_json,
|
|
input.notes,
|
|
options_json,
|
|
sort_order,
|
|
],
|
|
)
|
|
.map_err(|e| format!("Failed to insert connection '{}': {e}", input.name))?;
|
|
|
|
let id = conn.last_insert_rowid();
|
|
|
|
Ok(ConnectionRecord {
|
|
id,
|
|
name: input.name,
|
|
hostname: input.hostname,
|
|
port: input.port,
|
|
protocol: input.protocol,
|
|
group_id: input.group_id,
|
|
credential_id: input.credential_id,
|
|
color: input.color,
|
|
tags: input.tags,
|
|
notes: input.notes,
|
|
options: options_json,
|
|
sort_order,
|
|
last_connected: None,
|
|
})
|
|
}
|
|
|
|
/// Fetch a single connection by ID.
|
|
pub fn get_connection(&self, id: i64) -> Result<ConnectionRecord, String> {
|
|
let conn = self.db.conn();
|
|
conn.query_row(
|
|
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
|
color, tags, notes, options, sort_order, last_connected
|
|
FROM connections
|
|
WHERE id = ?1",
|
|
params![id],
|
|
map_connection_row,
|
|
)
|
|
.map_err(|e| match e {
|
|
rusqlite::Error::QueryReturnedNoRows => format!("Connection {id} not found"),
|
|
other => format!("Failed to fetch connection {id}: {other}"),
|
|
})
|
|
}
|
|
|
|
/// Return all connections ordered by `(group_id NULLS FIRST, sort_order)`.
|
|
pub fn list_connections(&self) -> Result<Vec<ConnectionRecord>, String> {
|
|
let conn = self.db.conn();
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
|
color, tags, notes, options, sort_order, last_connected
|
|
FROM connections
|
|
ORDER BY group_id NULLS FIRST, sort_order ASC",
|
|
)
|
|
.map_err(|e| format!("Failed to prepare connection list query: {e}"))?;
|
|
|
|
let records = stmt
|
|
.query_map([], map_connection_row)
|
|
.map_err(|e| format!("Failed to list connections: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to read connection row: {e}"))?;
|
|
|
|
Ok(records)
|
|
}
|
|
|
|
/// Update a connection. Only fields that are `Some` in `input` are written.
|
|
///
|
|
/// Builds a dynamic `SET` clause so a round-trip for unchanged fields is
|
|
/// never needed from the frontend.
|
|
pub fn update_connection(
|
|
&self,
|
|
id: i64,
|
|
input: UpdateConnectionInput,
|
|
) -> Result<(), String> {
|
|
// Build the SET clause dynamically.
|
|
let mut set_clauses: Vec<String> = Vec::new();
|
|
let mut positional: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
|
|
macro_rules! push_field {
|
|
($field:expr, $col:literal) => {
|
|
if let Some(val) = $field {
|
|
positional.push(Box::new(val));
|
|
set_clauses.push(format!("{} = ?{}", $col, positional.len()));
|
|
}
|
|
};
|
|
}
|
|
|
|
push_field!(input.name, "name");
|
|
push_field!(input.hostname, "hostname");
|
|
push_field!(input.port, "port");
|
|
push_field!(input.protocol, "protocol");
|
|
|
|
// group_id: `Some(id)` sets to that id; `None` means "field not in
|
|
// payload, leave unchanged". Callers that need to clear group_id
|
|
// should send a separate dedicated endpoint rather than relying on
|
|
// null JSON serialisation, because `Option<i64>` cannot distinguish
|
|
// "absent" from "null" at this layer.
|
|
push_field!(input.group_id, "group_id");
|
|
push_field!(input.credential_id, "credential_id");
|
|
|
|
push_field!(input.color, "color");
|
|
|
|
if let Some(tags) = input.tags {
|
|
let tags_json = serde_json::to_string(&tags)
|
|
.map_err(|e| format!("Failed to serialise tags: {e}"))?;
|
|
positional.push(Box::new(tags_json));
|
|
set_clauses.push(format!("tags = ?{}", positional.len()));
|
|
}
|
|
|
|
push_field!(input.notes, "notes");
|
|
|
|
if let Some(opts) = input.options {
|
|
serde_json::from_str::<serde_json::Value>(&opts)
|
|
.map_err(|e| format!("options is not valid JSON: {e}"))?;
|
|
positional.push(Box::new(opts));
|
|
set_clauses.push(format!("options = ?{}", positional.len()));
|
|
}
|
|
|
|
if set_clauses.is_empty() {
|
|
// Nothing to update — treat as a no-op success.
|
|
return Ok(());
|
|
}
|
|
|
|
// Append updated_at and the WHERE id binding.
|
|
set_clauses.push("updated_at = CURRENT_TIMESTAMP".to_string());
|
|
positional.push(Box::new(id));
|
|
let id_pos = positional.len();
|
|
|
|
let sql = format!(
|
|
"UPDATE connections SET {} WHERE id = ?{}",
|
|
set_clauses.join(", "),
|
|
id_pos
|
|
);
|
|
|
|
let conn = self.db.conn();
|
|
let params_refs: Vec<&dyn rusqlite::ToSql> =
|
|
positional.iter().map(|b| b.as_ref()).collect();
|
|
|
|
let affected = conn
|
|
.execute(&sql, params_refs.as_slice())
|
|
.map_err(|e| format!("Failed to update connection {id}: {e}"))?;
|
|
|
|
if affected == 0 {
|
|
return Err(format!("Connection {id} not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a connection by ID.
|
|
pub fn delete_connection(&self, id: i64) -> Result<(), String> {
|
|
let conn = self.db.conn();
|
|
let affected = conn
|
|
.execute("DELETE FROM connections WHERE id = ?1", params![id])
|
|
.map_err(|e| format!("Failed to delete connection {id}: {e}"))?;
|
|
|
|
if affected == 0 {
|
|
return Err(format!("Connection {id} not found"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Search connections by `query` across `name`, `hostname`, `tags`, and
|
|
/// `notes` using case-insensitive LIKE matching.
|
|
///
|
|
/// Returns results ordered by name.
|
|
pub fn search(&self, query: &str) -> Result<Vec<ConnectionRecord>, String> {
|
|
let conn = self.db.conn();
|
|
|
|
// Wrap query in `%…%` for LIKE. Escape any literal `%` or `_` in the
|
|
// query so they're treated as ordinary characters.
|
|
let like_pattern = format!(
|
|
"%{}%",
|
|
query.replace('%', "\\%").replace('_', "\\_")
|
|
);
|
|
|
|
let mut stmt = conn
|
|
.prepare(
|
|
"SELECT id, name, hostname, port, protocol, group_id, credential_id,
|
|
color, tags, notes, options, sort_order, last_connected
|
|
FROM connections
|
|
WHERE name LIKE ?1 ESCAPE '\\'
|
|
OR hostname LIKE ?1 ESCAPE '\\'
|
|
OR tags LIKE ?1 ESCAPE '\\'
|
|
OR notes LIKE ?1 ESCAPE '\\'
|
|
ORDER BY name COLLATE NOCASE ASC",
|
|
)
|
|
.map_err(|e| format!("Failed to prepare search query: {e}"))?;
|
|
|
|
let records = stmt
|
|
.query_map(params![like_pattern], map_connection_row)
|
|
.map_err(|e| format!("Search query failed: {e}"))?
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.map_err(|e| format!("Failed to read search result row: {e}"))?;
|
|
|
|
Ok(records)
|
|
}
|
|
|
|
/// Batch-update sort_order for a list of connection IDs.
|
|
pub fn reorder_connections(&self, ids: &[i64]) -> Result<(), String> {
|
|
let conn = self.db.conn();
|
|
for (i, id) in ids.iter().enumerate() {
|
|
conn.execute(
|
|
"UPDATE connections SET sort_order = ?1 WHERE id = ?2",
|
|
params![i as i64, id],
|
|
)
|
|
.map_err(|e| format!("Failed to reorder connection {id}: {e}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Batch-update sort_order for a list of group IDs.
|
|
pub fn reorder_groups(&self, ids: &[i64]) -> Result<(), String> {
|
|
let conn = self.db.conn();
|
|
for (i, id) in ids.iter().enumerate() {
|
|
conn.execute(
|
|
"UPDATE groups SET sort_order = ?1 WHERE id = ?2",
|
|
params![i as i64, id],
|
|
)
|
|
.map_err(|e| format!("Failed to reorder group {id}: {e}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ── private helpers ───────────────────────────────────────────────────────────
|
|
|
|
/// Map a rusqlite row from the `connections` SELECT into a [`ConnectionRecord`].
|
|
///
|
|
/// Column order must match the SELECT lists used throughout this module.
|
|
fn map_connection_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConnectionRecord> {
|
|
let tags_json: String = row.get(8)?;
|
|
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
|
|
Ok(ConnectionRecord {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
hostname: row.get(2)?,
|
|
port: row.get(3)?,
|
|
protocol: row.get(4)?,
|
|
group_id: row.get::<_, Option<i64>>(5)?,
|
|
credential_id: row.get::<_, Option<i64>>(6)?,
|
|
color: row.get::<_, Option<String>>(7)?,
|
|
tags,
|
|
notes: row.get::<_, Option<String>>(9)?,
|
|
options: row.get(10)?,
|
|
sort_order: row.get(11)?,
|
|
last_connected: row.get::<_, Option<String>>(12)?,
|
|
})
|
|
}
|
|
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::Database;
|
|
|
|
fn make_service() -> ConnectionService {
|
|
let db = Database::open(std::path::Path::new(":memory:")).unwrap();
|
|
db.migrate().unwrap();
|
|
ConnectionService::new(db)
|
|
}
|
|
|
|
fn default_input(name: &str) -> CreateConnectionInput {
|
|
CreateConnectionInput {
|
|
name: name.to_string(),
|
|
hostname: "192.168.1.1".to_string(),
|
|
port: 22,
|
|
protocol: "ssh".to_string(),
|
|
group_id: None,
|
|
credential_id: None,
|
|
color: None,
|
|
tags: vec!["linux".to_string(), "prod".to_string()],
|
|
notes: None,
|
|
options: None,
|
|
}
|
|
}
|
|
|
|
// ── group tests ───────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn create_and_list_group() {
|
|
let svc = make_service();
|
|
let g = svc.create_group("Production", None).unwrap();
|
|
assert_eq!(g.name, "Production");
|
|
assert_eq!(g.parent_id, None);
|
|
|
|
let groups = svc.list_groups().unwrap();
|
|
assert_eq!(groups.len(), 1);
|
|
assert_eq!(groups[0].id, g.id);
|
|
}
|
|
|
|
#[test]
|
|
fn create_nested_group() {
|
|
let svc = make_service();
|
|
let parent = svc.create_group("Root", None).unwrap();
|
|
let child = svc.create_group("Child", Some(parent.id)).unwrap();
|
|
assert_eq!(child.parent_id, Some(parent.id));
|
|
|
|
let groups = svc.list_groups().unwrap();
|
|
assert_eq!(groups.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn rename_group() {
|
|
let svc = make_service();
|
|
let g = svc.create_group("OldName", None).unwrap();
|
|
svc.rename_group(g.id, "NewName").unwrap();
|
|
|
|
let groups = svc.list_groups().unwrap();
|
|
assert_eq!(groups[0].name, "NewName");
|
|
}
|
|
|
|
#[test]
|
|
fn rename_nonexistent_group_errors() {
|
|
let svc = make_service();
|
|
assert!(svc.rename_group(999, "X").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_group() {
|
|
let svc = make_service();
|
|
let g = svc.create_group("Temp", None).unwrap();
|
|
svc.delete_group(g.id).unwrap();
|
|
assert!(svc.list_groups().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_nonexistent_group_errors() {
|
|
let svc = make_service();
|
|
assert!(svc.delete_group(999).is_err());
|
|
}
|
|
|
|
// ── connection tests ──────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn create_and_get_connection() {
|
|
let svc = make_service();
|
|
let rec = svc.create_connection(default_input("web-01")).unwrap();
|
|
assert!(rec.id > 0);
|
|
assert_eq!(rec.name, "web-01");
|
|
assert_eq!(rec.port, 22);
|
|
assert_eq!(rec.options, "{}");
|
|
|
|
let fetched = svc.get_connection(rec.id).unwrap();
|
|
assert_eq!(fetched.id, rec.id);
|
|
assert_eq!(fetched.hostname, "192.168.1.1");
|
|
}
|
|
|
|
#[test]
|
|
fn tags_serialised_as_vec() {
|
|
let svc = make_service();
|
|
let rec = svc.create_connection(default_input("tagged")).unwrap();
|
|
assert_eq!(rec.tags, vec!["linux", "prod"]);
|
|
}
|
|
|
|
#[test]
|
|
fn list_connections_empty() {
|
|
let svc = make_service();
|
|
assert!(svc.list_connections().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn list_connections_multiple() {
|
|
let svc = make_service();
|
|
svc.create_connection(default_input("alpha")).unwrap();
|
|
svc.create_connection(default_input("beta")).unwrap();
|
|
assert_eq!(svc.list_connections().unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn update_connection_name() {
|
|
let svc = make_service();
|
|
let rec = svc.create_connection(default_input("original")).unwrap();
|
|
|
|
svc.update_connection(
|
|
rec.id,
|
|
UpdateConnectionInput {
|
|
name: Some("renamed".to_string()),
|
|
hostname: None,
|
|
port: None,
|
|
protocol: None,
|
|
group_id: None,
|
|
credential_id: None,
|
|
color: None,
|
|
tags: None,
|
|
notes: None,
|
|
options: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let updated = svc.get_connection(rec.id).unwrap();
|
|
assert_eq!(updated.name, "renamed");
|
|
// Untouched fields stay the same.
|
|
assert_eq!(updated.hostname, "192.168.1.1");
|
|
}
|
|
|
|
#[test]
|
|
fn update_connection_tags() {
|
|
let svc = make_service();
|
|
let rec = svc.create_connection(default_input("server")).unwrap();
|
|
|
|
svc.update_connection(
|
|
rec.id,
|
|
UpdateConnectionInput {
|
|
name: None,
|
|
hostname: None,
|
|
port: None,
|
|
protocol: None,
|
|
group_id: None,
|
|
credential_id: None,
|
|
color: None,
|
|
tags: Some(vec!["windows".to_string()]),
|
|
notes: None,
|
|
options: None,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let updated = svc.get_connection(rec.id).unwrap();
|
|
assert_eq!(updated.tags, vec!["windows"]);
|
|
}
|
|
|
|
#[test]
|
|
fn update_nonexistent_connection_errors() {
|
|
let svc = make_service();
|
|
let result = svc.update_connection(
|
|
9999,
|
|
UpdateConnectionInput {
|
|
name: Some("x".to_string()),
|
|
hostname: None,
|
|
port: None,
|
|
protocol: None,
|
|
group_id: None,
|
|
credential_id: None,
|
|
color: None,
|
|
tags: None,
|
|
notes: None,
|
|
options: None,
|
|
},
|
|
);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_connection() {
|
|
let svc = make_service();
|
|
let rec = svc.create_connection(default_input("to-delete")).unwrap();
|
|
svc.delete_connection(rec.id).unwrap();
|
|
assert!(svc.get_connection(rec.id).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn delete_nonexistent_connection_errors() {
|
|
let svc = make_service();
|
|
assert!(svc.delete_connection(999).is_err());
|
|
}
|
|
|
|
// ── search tests ──────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn search_by_name() {
|
|
let svc = make_service();
|
|
svc.create_connection(default_input("web-server-01")).unwrap();
|
|
svc.create_connection(default_input("db-server-01")).unwrap();
|
|
|
|
let results = svc.search("web").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].name, "web-server-01");
|
|
}
|
|
|
|
#[test]
|
|
fn search_by_hostname() {
|
|
let svc = make_service();
|
|
let mut input = default_input("db");
|
|
input.hostname = "db.internal.example.com".to_string();
|
|
svc.create_connection(input).unwrap();
|
|
|
|
let results = svc.search("internal").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn search_case_insensitive() {
|
|
let svc = make_service();
|
|
svc.create_connection(default_input("MyServer")).unwrap();
|
|
|
|
assert_eq!(svc.search("myserver").unwrap().len(), 1);
|
|
assert_eq!(svc.search("MYSERVER").unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn search_no_results() {
|
|
let svc = make_service();
|
|
svc.create_connection(default_input("alpha")).unwrap();
|
|
assert!(svc.search("zzz").unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn search_special_like_chars_not_treated_as_wildcards() {
|
|
let svc = make_service();
|
|
let mut input = default_input("literal_underscore");
|
|
input.hostname = "host_name".to_string();
|
|
svc.create_connection(input).unwrap();
|
|
|
|
// "_" as a LIKE wildcard would match everything single-char wide.
|
|
// After escaping it should only match "host_name".
|
|
let results = svc.search("host_name").unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
}
|
|
}
|