tftsr-devops_investigation/src-tauri/src/integrations/servicenow.rs
Shaun Arman a7903db904
Some checks failed
Release / build-macos-arm64 (push) Successful in 4m52s
Release / build-linux-amd64 (push) Has been cancelled
Release / build-linux-arm64 (push) Has been cancelled
Release / build-windows-amd64 (push) Has been cancelled
fix: persist integration settings and implement persistent browser windows
## Integration Settings Persistence
- Add database commands to save/load integration configs (base_url, username, project_name, space_key)
- Frontend now loads configs from DB on mount and saves changes automatically
- Fixes issue where settings were lost on app restart

## Persistent Browser Window Architecture
- Integration browser windows now stay open for user browsing and authentication
- Extract fresh cookies before each API call to handle token rotation
- Track open windows in app state (integration_webviews HashMap)
- Windows titled as "{Service} Browser (TFTSR)" for clarity
- Support easy navigation between app and browser windows (Cmd+Tab/Alt+Tab)
- Gracefully handle closed windows with automatic cleanup

## Bug Fixes
- Fix Rust formatting issues across 8 files
- Fix clippy warnings:
  - Use is_some_and() instead of map_or() in openai.rs
  - Use .to_string() instead of format!() in integrations.rs
- Add missing OptionalExtension import for .optional() method

## Tests
- Add test_integration_config_serialization
- Add test_webview_tracking
- Add test_token_auth_request_serialization
- All 6 integration tests passing

## Files Modified
- src-tauri/src/state.rs: Add integration_webviews tracking
- src-tauri/src/lib.rs: Register 3 new commands, initialize webviews HashMap
- src-tauri/src/commands/integrations.rs: Config persistence, fresh cookie extraction (+151 lines)
- src-tauri/src/integrations/webview_auth.rs: Persistent window behavior
- src/lib/tauriCommands.ts: TypeScript wrappers for new commands
- src/pages/Settings/Integrations.tsx: Load/save configs from DB

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-04 09:57:22 -05:00

569 lines
17 KiB
Rust

use serde::{Deserialize, Serialize};
use super::{ConnectionResult, TicketResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceNowConfig {
pub instance_url: String,
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Incident {
pub sys_id: String,
pub number: String,
pub short_description: String,
pub description: String,
pub urgency: String,
pub impact: String,
pub state: String,
}
/// Test connection to ServiceNow by querying a single incident
pub async fn test_connection(config: &ServiceNowConfig) -> Result<ConnectionResult, String> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/now/table/incident",
config.instance_url.trim_end_matches('/')
);
let resp = client
.get(&url)
.basic_auth(&config.username, Some(&config.password))
.query(&[("sysparm_limit", "1")])
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
if resp.status().is_success() {
Ok(ConnectionResult {
success: true,
message: "Successfully connected to ServiceNow".to_string(),
})
} else {
Ok(ConnectionResult {
success: false,
message: format!("Connection failed with status: {}", resp.status()),
})
}
}
/// Search for incidents by description or number
pub async fn search_incidents(
config: &ServiceNowConfig,
query: &str,
) -> Result<Vec<Incident>, String> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/now/table/incident",
config.instance_url.trim_end_matches('/')
);
let sysparm_query = format!("short_descriptionLIKE{}", query);
let resp = client
.get(&url)
.basic_auth(&config.username, Some(&config.password))
.query(&[
("sysparm_query", &sysparm_query),
("sysparm_limit", &"10".to_string()),
])
.send()
.await
.map_err(|e| format!("Search failed: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"Search failed: {} - {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let incidents = body["result"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|i| {
Some(Incident {
sys_id: i["sys_id"].as_str()?.to_string(),
number: i["number"].as_str()?.to_string(),
short_description: i["short_description"].as_str()?.to_string(),
description: i["description"].as_str().unwrap_or("").to_string(),
urgency: i["urgency"].as_str().unwrap_or("3").to_string(),
impact: i["impact"].as_str().unwrap_or("3").to_string(),
state: i["state"].as_str().unwrap_or("1").to_string(),
})
})
.collect();
Ok(incidents)
}
/// Create a new incident in ServiceNow
pub async fn create_incident(
config: &ServiceNowConfig,
short_description: &str,
description: &str,
urgency: &str,
impact: &str,
) -> Result<TicketResult, String> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/now/table/incident",
config.instance_url.trim_end_matches('/')
);
let body = serde_json::json!({
"short_description": short_description,
"description": description,
"urgency": urgency,
"impact": impact,
});
let resp = client
.post(&url)
.basic_auth(&config.username, Some(&config.password))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.map_err(|e| format!("Failed to create incident: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"Failed to create incident: {} - {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}
let result: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let incident_number = result["result"]["number"].as_str().unwrap_or("");
let sys_id = result["result"]["sys_id"].as_str().unwrap_or("");
let incident_url = format!(
"{}/nav_to.do?uri=incident.do?sys_id={}",
config.instance_url.trim_end_matches('/'),
sys_id
);
Ok(TicketResult {
id: sys_id.to_string(),
ticket_number: incident_number.to_string(),
url: incident_url,
})
}
/// Get an incident by sys_id or number
pub async fn get_incident(
config: &ServiceNowConfig,
incident_id: &str,
) -> Result<Incident, String> {
let client = reqwest::Client::new();
// Determine if incident_id is a sys_id or incident number
let (url, use_query) = if incident_id.starts_with("INC") {
// It's an incident number, use query parameter
(
format!(
"{}/api/now/table/incident",
config.instance_url.trim_end_matches('/')
),
true,
)
} else {
// It's a sys_id, use direct path
(
format!(
"{}/api/now/table/incident/{}",
config.instance_url.trim_end_matches('/'),
incident_id
),
false,
)
};
let mut request = client
.get(&url)
.basic_auth(&config.username, Some(&config.password));
if use_query {
request = request.query(&[("sysparm_query", &format!("number={}", incident_id))]);
}
let resp = request
.send()
.await
.map_err(|e| format!("Failed to get incident: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"Failed to get incident: {} - {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let incident_data = if use_query {
// Query response has "result" array
body["result"]
.as_array()
.and_then(|arr| arr.first())
.ok_or_else(|| "Incident not found".to_string())?
} else {
// Direct sys_id response has "result" object
&body["result"]
};
Ok(Incident {
sys_id: incident_data["sys_id"]
.as_str()
.ok_or_else(|| "Missing sys_id".to_string())?
.to_string(),
number: incident_data["number"]
.as_str()
.ok_or_else(|| "Missing number".to_string())?
.to_string(),
short_description: incident_data["short_description"]
.as_str()
.ok_or_else(|| "Missing short_description".to_string())?
.to_string(),
description: incident_data["description"]
.as_str()
.unwrap_or("")
.to_string(),
urgency: incident_data["urgency"].as_str().unwrap_or("3").to_string(),
impact: incident_data["impact"].as_str().unwrap_or("3").to_string(),
state: incident_data["state"].as_str().unwrap_or("1").to_string(),
})
}
/// Update an existing incident
pub async fn update_incident(
config: &ServiceNowConfig,
sys_id: &str,
updates: serde_json::Value,
) -> Result<TicketResult, String> {
let client = reqwest::Client::new();
let url = format!(
"{}/api/now/table/incident/{}",
config.instance_url.trim_end_matches('/'),
sys_id
);
let resp = client
.patch(&url)
.basic_auth(&config.username, Some(&config.password))
.header("Content-Type", "application/json")
.json(&updates)
.send()
.await
.map_err(|e| format!("Failed to update incident: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"Failed to update incident: {} - {}",
resp.status(),
resp.text().await.unwrap_or_default()
));
}
let result: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
let incident_number = result["result"]["number"].as_str().unwrap_or("");
let updated_sys_id = result["result"]["sys_id"].as_str().unwrap_or(sys_id);
let incident_url = format!(
"{}/nav_to.do?uri=incident.do?sys_id={}",
config.instance_url.trim_end_matches('/'),
updated_sys_id
);
Ok(TicketResult {
id: updated_sys_id.to_string(),
ticket_number: incident_number.to_string(),
url: incident_url,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connection_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/now/table/incident")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"sysparm_limit".into(),
"1".into(),
)]))
.with_status(200)
.with_body(r#"{"result":[]}"#)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let result = test_connection(&config).await;
mock.assert_async().await;
assert!(result.is_ok());
let conn = result.unwrap();
assert!(conn.success);
assert!(conn.message.contains("Successfully connected"));
}
#[tokio::test]
async fn test_connection_failure() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/now/table/incident")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"sysparm_limit".into(),
"1".into(),
)]))
.with_status(401)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "wrong_password".to_string(),
};
let result = test_connection(&config).await;
mock.assert_async().await;
assert!(result.is_ok());
let conn = result.unwrap();
assert!(!conn.success);
}
#[tokio::test]
async fn test_search_incidents() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/now/table/incident")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded(
"sysparm_query".into(),
"short_descriptionLIKElogin".into(),
),
mockito::Matcher::UrlEncoded("sysparm_limit".into(), "10".into()),
]))
.with_status(200)
.with_body(
r#"{
"result": [
{
"sys_id": "abc123",
"number": "INC0010001",
"short_description": "Login issue",
"description": "Users cannot login",
"urgency": "2",
"impact": "2",
"state": "2"
}
]
}"#,
)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let result = search_incidents(&config, "login").await;
mock.assert_async().await;
assert!(result.is_ok());
let incidents = result.unwrap();
assert_eq!(incidents.len(), 1);
assert_eq!(incidents[0].number, "INC0010001");
assert_eq!(incidents[0].short_description, "Login issue");
}
#[tokio::test]
async fn test_create_incident() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/now/table/incident")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.match_header("content-type", "application/json")
.with_status(201)
.with_body(
r#"{
"result": {
"sys_id": "def456",
"number": "INC0010002"
}
}"#,
)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let result = create_incident(&config, "Test issue", "Description", "3", "3").await;
mock.assert_async().await;
assert!(result.is_ok());
let ticket = result.unwrap();
assert_eq!(ticket.ticket_number, "INC0010002");
assert_eq!(ticket.id, "def456");
assert!(ticket.url.contains("def456"));
}
#[tokio::test]
async fn test_get_incident_by_sys_id() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/now/table/incident/abc123")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.with_status(200)
.with_body(
r#"{
"result": {
"sys_id": "abc123",
"number": "INC0010001",
"short_description": "Login issue",
"description": "Users cannot login",
"urgency": "2",
"impact": "2",
"state": "2"
}
}"#,
)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let result = get_incident(&config, "abc123").await;
mock.assert_async().await;
assert!(result.is_ok());
let incident = result.unwrap();
assert_eq!(incident.sys_id, "abc123");
assert_eq!(incident.number, "INC0010001");
}
#[tokio::test]
async fn test_get_incident_by_number() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/now/table/incident")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"sysparm_query".into(),
"number=INC0010001".into(),
)]))
.with_status(200)
.with_body(
r#"{
"result": [{
"sys_id": "abc123",
"number": "INC0010001",
"short_description": "Login issue",
"description": "Users cannot login",
"urgency": "2",
"impact": "2",
"state": "2"
}]
}"#,
)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let result = get_incident(&config, "INC0010001").await;
mock.assert_async().await;
assert!(result.is_ok());
let incident = result.unwrap();
assert_eq!(incident.sys_id, "abc123");
assert_eq!(incident.number, "INC0010001");
}
#[tokio::test]
async fn test_update_incident() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("PATCH", "/api/now/table/incident/abc123")
.match_header("authorization", mockito::Matcher::Regex("Basic .+".into()))
.match_header("content-type", "application/json")
.with_status(200)
.with_body(
r#"{
"result": {
"sys_id": "abc123",
"number": "INC0010001"
}
}"#,
)
.create_async()
.await;
let config = ServiceNowConfig {
instance_url: server.url(),
username: "admin".to_string(),
password: "password".to_string(),
};
let updates = serde_json::json!({
"state": "6",
"close_notes": "Issue resolved"
});
let result = update_incident(&config, "abc123", updates).await;
mock.assert_async().await;
assert!(result.is_ok());
let ticket = result.unwrap();
assert_eq!(ticket.id, "abc123");
assert_eq!(ticket.ticket_number, "INC0010001");
}
}