From 3cf28babab6b07ff0023f97c988a183297839fca Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Mon, 13 Apr 2026 14:26:13 +0200 Subject: [PATCH] feat: TuleapCredentials model + encrypted storage + Tauri commands Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/commands/credential.rs | 80 +++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 4 + src-tauri/src/models/credential.rs | 165 +++++++++++++++++++++++++++ src-tauri/src/models/mod.rs | 1 + 5 files changed, 251 insertions(+) create mode 100644 src-tauri/src/commands/credential.rs create mode 100644 src-tauri/src/models/credential.rs diff --git a/src-tauri/src/commands/credential.rs b/src-tauri/src/commands/credential.rs new file mode 100644 index 0000000..954c9f9 --- /dev/null +++ b/src-tauri/src/commands/credential.rs @@ -0,0 +1,80 @@ +use crate::error::AppError; +use crate::models::credential::{TuleapCredentials, TuleapCredentialsSafe}; +use crate::services::crypto; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub fn set_tuleap_credentials( + state: State<'_, AppState>, + tuleap_url: String, + username: String, + password: String, +) -> Result { + let password_encrypted = crypto::encrypt(&state.encryption_key, &password) + .map_err(AppError::from)?; + + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let creds = TuleapCredentials::upsert(&db, &tuleap_url, &username, &password_encrypted)?; + Ok(creds.to_safe()) +} + +#[tauri::command] +pub fn get_tuleap_credentials( + state: State<'_, AppState>, +) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let result = TuleapCredentials::get(&db)?.map(|c| c.to_safe()); + Ok(result) +} + +#[tauri::command] +pub fn delete_tuleap_credentials(state: State<'_, AppState>) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + TuleapCredentials::delete(&db)?; + Ok(()) +} + +#[tauri::command] +pub async fn test_tuleap_connection(state: State<'_, AppState>) -> Result { + let (tuleap_url, username, password) = { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let creds = TuleapCredentials::get(&db)? + .ok_or_else(|| AppError::from("No credentials configured".to_string()))?; + + let password = crypto::decrypt(&state.encryption_key, &creds.password_encrypted) + .map_err(AppError::from)?; + + (creds.tuleap_url, creds.username, password) + }; + + let url = format!("{}/api/projects?limit=1", tuleap_url.trim_end_matches('/')); + + state + .http_client + .get(&url) + .basic_auth(&username, Some(&password)) + .send() + .await + .map_err(AppError::from)? + .error_for_status() + .map_err(AppError::from)?; + + Ok("Connection successful".to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 36df406..29988c6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1 +1,2 @@ +pub mod credential; pub mod project; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3317ab2..95d0909 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,10 @@ pub fn run() { commands::project::get_project, commands::project::update_project, commands::project::delete_project, + commands::credential::set_tuleap_credentials, + commands::credential::get_tuleap_credentials, + commands::credential::delete_tuleap_credentials, + commands::credential::test_tuleap_connection, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/models/credential.rs b/src-tauri/src/models/credential.rs new file mode 100644 index 0000000..0849947 --- /dev/null +++ b/src-tauri/src/models/credential.rs @@ -0,0 +1,165 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuleapCredentials { + pub id: String, + pub tuleap_url: String, + pub username: String, + pub password_encrypted: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TuleapCredentialsSafe { + pub id: String, + pub tuleap_url: String, + pub username: String, +} + +impl TuleapCredentials { + pub fn upsert( + conn: &Connection, + tuleap_url: &str, + username: &str, + password_encrypted: &str, + ) -> Result { + conn.execute("DELETE FROM tuleap_credentials", [])?; + + let id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO tuleap_credentials (id, tuleap_url, username, password_encrypted) VALUES (?1, ?2, ?3, ?4)", + params![id, tuleap_url, username, password_encrypted], + )?; + + Ok(TuleapCredentials { + id, + tuleap_url: tuleap_url.to_string(), + username: username.to_string(), + password_encrypted: password_encrypted.to_string(), + }) + } + + pub fn get(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, tuleap_url, username, password_encrypted FROM tuleap_credentials LIMIT 1", + )?; + let mut rows = stmt.query_map([], |row| { + Ok(TuleapCredentials { + id: row.get(0)?, + tuleap_url: row.get(1)?, + username: row.get(2)?, + password_encrypted: row.get(3)?, + }) + })?; + + match rows.next() { + Some(row) => Ok(Some(row?)), + None => Ok(None), + } + } + + pub fn delete(conn: &Connection) -> Result<()> { + conn.execute("DELETE FROM tuleap_credentials", [])?; + Ok(()) + } + + pub fn to_safe(&self) -> TuleapCredentialsSafe { + TuleapCredentialsSafe { + id: self.id.clone(), + tuleap_url: self.tuleap_url.clone(), + username: self.username.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + fn setup() -> Connection { + db::init_in_memory().expect("db init should succeed") + } + + #[test] + fn test_upsert_creates_credentials() { + let conn = setup(); + let creds = TuleapCredentials::upsert( + &conn, + "https://tuleap.example.com", + "alice", + "encrypted_password", + ) + .expect("upsert should succeed"); + + assert_eq!(creds.tuleap_url, "https://tuleap.example.com"); + assert_eq!(creds.username, "alice"); + assert_eq!(creds.password_encrypted, "encrypted_password"); + assert!(!creds.id.is_empty()); + } + + #[test] + fn test_upsert_replaces_existing() { + let conn = setup(); + TuleapCredentials::upsert(&conn, "https://old.example.com", "old_user", "old_enc") + .expect("first upsert should succeed"); + + let second = TuleapCredentials::upsert( + &conn, + "https://new.example.com", + "new_user", + "new_enc", + ) + .expect("second upsert should succeed"); + + // Only one record should exist + let creds = TuleapCredentials::get(&conn) + .expect("get should succeed") + .expect("should have credentials"); + + assert_eq!(creds.id, second.id); + assert_eq!(creds.tuleap_url, "https://new.example.com"); + assert_eq!(creds.username, "new_user"); + } + + #[test] + fn test_get_returns_none_when_empty() { + let conn = setup(); + let result = TuleapCredentials::get(&conn).expect("get should succeed"); + assert!(result.is_none()); + } + + #[test] + fn test_get_returns_credentials() { + let conn = setup(); + let created = TuleapCredentials::upsert( + &conn, + "https://tuleap.example.com", + "bob", + "enc_pass", + ) + .expect("upsert should succeed"); + + let fetched = TuleapCredentials::get(&conn) + .expect("get should succeed") + .expect("should have credentials"); + + assert_eq!(fetched.id, created.id); + assert_eq!(fetched.tuleap_url, "https://tuleap.example.com"); + assert_eq!(fetched.username, "bob"); + assert_eq!(fetched.password_encrypted, "enc_pass"); + } + + #[test] + fn test_delete_removes_credentials() { + let conn = setup(); + TuleapCredentials::upsert(&conn, "https://tuleap.example.com", "carol", "enc") + .expect("upsert should succeed"); + + TuleapCredentials::delete(&conn).expect("delete should succeed"); + + let result = TuleapCredentials::get(&conn).expect("get should succeed"); + assert!(result.is_none()); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 36df406..29988c6 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1 +1,2 @@ +pub mod credential; pub mod project;