diff --git a/crates/app-tauri/src/server.rs b/crates/app-tauri/src/server.rs index b10e614..c9953cd 100644 --- a/crates/app-tauri/src/server.rs +++ b/crates/app-tauri/src/server.rs @@ -24,8 +24,13 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use uuid::Uuid; -use crate::commands::open_project_for_adapter; -use crate::dto::{ErrorDto, HealthRequestDto, HealthResponseDto, ProjectListDto}; +use application::{GetProjectWorkStateInput, OpenProjectInput}; +use domain::Project; + +use crate::dto::{ + parse_project_id, ErrorDto, HealthRequestDto, HealthResponseDto, ProjectDto, ProjectListDto, + ProjectWorkStateDto, +}; use crate::state::AppState; const DEFAULT_LISTEN: &str = "127.0.0.1:17373"; @@ -525,6 +530,7 @@ async fn invoke( "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, + "get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await, _ => Err(ErrorDto { code: "UNKNOWN_COMMAND".to_owned(), message: format!("unknown command: {}", request.command), @@ -565,10 +571,38 @@ async fn invoke_open_project(args: &Value, state: &AppState) -> Result Result { + let project_id = args + .get("projectId") + .and_then(Value::as_str) + .ok_or_else(|| ErrorDto { + code: "INVALID".to_owned(), + message: "get_project_work_state requires args.projectId".to_owned(), + })?; + let project = resolve_project_readonly(project_id, state).await?; + let output = state + .get_project_work_state + .execute(GetProjectWorkStateInput { project }) + .await + .map(ProjectWorkStateDto::from) + .map_err(ErrorDto::from)?; serde_json::to_value(output).map_err(serialization_error) } +async fn resolve_project_readonly(project_id: &str, state: &AppState) -> Result { + let id = parse_project_id(project_id)?; + state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map(|output| output.project) + .map_err(ErrorDto::from) +} + fn optional_request(args: &Value) -> Result, ErrorDto> where T: for<'de> Deserialize<'de>, @@ -794,10 +828,9 @@ fn serialization_error(err: serde_json::Error) -> ErrorDto { #[cfg(test)] mod tests { use super::*; + use application::CreateProjectInput; use http::header::HeaderName; - use crate::dto::parse_project_id; - fn test_config() -> ServerConfig { ServerConfig { listen: "127.0.0.1:17373".parse().unwrap(), @@ -869,6 +902,25 @@ mod tests { .to_owned() } + async fn create_project_for_test(state: &Arc, name: &str) -> String { + let root = std::env::temp_dir() + .join(format!("idea-server-project-{}", Uuid::new_v4())) + .to_string_lossy() + .into_owned(); + let output = state + .app + .create_project + .execute(CreateProjectInput { + name: name.to_owned(), + root, + remote: None, + default_profile_id: None, + }) + .await + .expect("test project is created"); + output.project.id.to_string() + } + #[test] fn public_bind_requires_explicit_remote_security() { let config = ServerConfig { @@ -1104,6 +1156,103 @@ mod tests { assert_eq!(body["note"], "hi"); } + #[tokio::test] + async fn authorized_list_projects_returns_tauri_project_list_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Web Read").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "list_projects", "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + let projects = body + .as_array() + .expect("ProjectListDto is a transparent array"); + let project = projects + .iter() + .find(|project| project["id"] == project_id) + .expect("created project is listed"); + assert_eq!(project["name"], "Web Read"); + assert!(project["root"] + .as_str() + .is_some_and(|root| root.contains("idea-server-project-"))); + } + + #[tokio::test] + async fn authorized_open_project_returns_readonly_project_dto() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Open").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], project_id); + assert_eq!(body["name"], "Readonly Open"); + assert!(body["root"].is_string()); + } + + #[tokio::test] + async fn authorized_get_project_work_state_returns_tauri_contract() { + let state = state(); + let project_id = create_project_for_test(&state, "Readonly Work").await; + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(body["agents"].as_array().is_some()); + assert!(body["conversations"].as_array().is_some()); + } + + #[tokio::test] + async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + for command in ["create_project", "open_terminal", "launch_agent"] { + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": command, "args": {} }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["code"], "UNKNOWN_COMMAND", + "{command} must stay blocked" + ); + } + } + #[tokio::test] async fn invalid_project_id_is_mapped_to_error_dto() { let state = state();