Files
IdeA/crates/app-tauri/src/server.rs
Blomios fa353f6c0b feat(server): allowlist read-only (get_project_work_state, open_project read-only) (#13)
Lot B4 du chantier server/client mode : extension read-only de l'allowlist
du serveur --serve pour clôturer le premier incrément livrable (sans PTY).

- open_project exposé en read-only via resolve_project_readonly.
- get_project_work_state ajouté à l'allowlist.
- 4 nouveaux tests.

Validé : cargo check --workspace vert, app-tauri 265 tests verts (dont les
4 tests B4), contrat B4↔F2 aligné (list_projects/open_project/
get_project_work_state), desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:21:28 +02:00

1280 lines
39 KiB
Rust

//! Secure HTTP driving adapter for `idea --serve`.
//!
//! B3 deliberately exposes only request/response RPC over `/api/invoke`; PTY and
//! live WebSocket streams are left to later lots. The shared backend core stays
//! unaware of HTTP, cookies and origins.
use std::collections::HashSet;
use std::env;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use cookie::{Cookie, SameSite};
use http::header::{HeaderValue, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
#[cfg(test)]
use http::Request;
use http::{HeaderMap, Method, Response, StatusCode, Uri};
use http_body_util::{BodyExt, Full};
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use uuid::Uuid;
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";
const SESSION_COOKIE: &str = "idea_session";
type ResponseBody = Full<Bytes>;
/// Runs the `idea --serve` subcommand from already-split CLI arguments.
pub fn run_from_args(args: Vec<String>) -> ExitCode {
let config = match ServerConfig::from_args(args).and_then(|config| {
config.validate()?;
Ok(config)
}) {
Ok(config) => config,
Err(err) => {
eprintln!("idea --serve: {err}");
return ExitCode::from(2);
}
};
let state = Arc::new(ServerState::new(config.clone()));
eprintln!("IdeA pairing code: {}", state.pairing_code());
eprintln!("IdeA server listening on {}", config.listen);
match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(run_server(config, state)) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("idea --serve: {err}");
ExitCode::from(1)
}
},
Err(err) => {
eprintln!("idea --serve: failed to build runtime: {err}");
ExitCode::from(1)
}
}
}
#[derive(Clone)]
struct ServerConfig {
listen: SocketAddr,
public_origin: Option<String>,
allow_remote: bool,
trust_reverse_proxy: bool,
app_data_dir: PathBuf,
}
impl ServerConfig {
fn from_args(args: Vec<String>) -> Result<Self, String> {
let mut listen = DEFAULT_LISTEN
.parse::<SocketAddr>()
.expect("default listen address is valid");
let mut public_origin = None;
let mut allow_remote = false;
let mut trust_reverse_proxy = false;
let mut app_data_dir = default_app_data_dir();
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--listen" => {
let value = it
.next()
.ok_or_else(|| "--listen requires an address".to_owned())?;
listen = value
.parse()
.map_err(|_| format!("invalid --listen address: {value}"))?;
}
"--public-origin" => {
let value = it
.next()
.ok_or_else(|| "--public-origin requires an origin".to_owned())?;
public_origin = Some(value);
}
"--allow-remote" => allow_remote = true,
"--trust-reverse-proxy" => trust_reverse_proxy = true,
"--app-data-dir" => {
let value = it
.next()
.ok_or_else(|| "--app-data-dir requires a path".to_owned())?;
app_data_dir = PathBuf::from(value);
}
"--help" | "-h" => return Err(Self::usage()),
other => return Err(format!("unknown --serve argument: {other}")),
}
}
Ok(Self {
listen,
public_origin,
allow_remote,
trust_reverse_proxy,
app_data_dir,
})
}
fn validate(&self) -> Result<(), String> {
if let Some(origin) = &self.public_origin {
validate_origin(origin)?;
}
if !self.listen.ip().is_loopback() && !self.allow_remote {
return Err(
"refusing non-loopback bind without --allow-remote and HTTPS proxy config"
.to_owned(),
);
}
if self.allow_remote {
let Some(origin) = &self.public_origin else {
return Err("--allow-remote requires --public-origin https://...".to_owned());
};
if !origin.starts_with("https://") {
return Err("--allow-remote requires an HTTPS public origin".to_owned());
}
if !self.trust_reverse_proxy {
return Err("--allow-remote requires --trust-reverse-proxy".to_owned());
}
}
Ok(())
}
fn secure_cookie(&self) -> bool {
self.allow_remote
|| self
.public_origin
.as_deref()
.is_some_and(|o| o.starts_with("https://"))
}
fn usage() -> String {
"usage: idea --serve [--listen IP:PORT] [--app-data-dir PATH] [--allow-remote --public-origin https://host --trust-reverse-proxy]".to_owned()
}
}
struct ServerState {
config: ServerConfig,
app: AppState,
pairing_code: String,
sessions: Mutex<HashSet<String>>,
}
impl ServerState {
fn new(config: ServerConfig) -> Self {
Self {
app: AppState::build(config.app_data_dir.clone()),
config,
pairing_code: new_pairing_code(),
sessions: Mutex::new(HashSet::new()),
}
}
#[cfg(test)]
fn new_for_test(config: ServerConfig, pairing_code: impl Into<String>) -> Self {
Self {
app: AppState::build(config.app_data_dir.clone()),
config,
pairing_code: pairing_code.into(),
sessions: Mutex::new(HashSet::new()),
}
}
fn pairing_code(&self) -> &str {
&self.pairing_code
}
fn create_session(&self) -> String {
let token = new_session_token();
if let Ok(mut sessions) = self.sessions.lock() {
sessions.insert(token.clone());
}
token
}
fn has_session(&self, token: &str) -> bool {
self.sessions
.lock()
.map(|sessions| sessions.contains(token))
.unwrap_or(false)
}
}
async fn run_server(config: ServerConfig, state: Arc<ServerState>) -> Result<(), String> {
let listener = TcpListener::bind(config.listen)
.await
.map_err(|err| format!("failed to bind {}: {err}", config.listen))?;
loop {
let (stream, _) = listener
.accept()
.await
.map_err(|err| format!("failed to accept connection: {err}"))?;
let state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(err) = handle_tcp_connection(stream, state).await {
eprintln!("idea --serve: connection error: {err}");
}
});
}
}
async fn handle_tcp_connection(
mut stream: tokio::net::TcpStream,
state: Arc<ServerState>,
) -> Result<(), String> {
let mut buffer = Vec::with_capacity(8192);
let mut chunk = [0_u8; 2048];
let header_end = loop {
let read = stream
.read(&mut chunk)
.await
.map_err(|err| format!("failed to read request: {err}"))?;
if read == 0 {
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.len() > 1024 * 1024 {
return Err("request too large".to_owned());
}
if let Some(pos) = find_header_end(&buffer) {
break pos;
}
};
let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?;
let body_start = header_end + 4;
while buffer.len() < body_start + content_length {
let read = stream
.read(&mut chunk)
.await
.map_err(|err| format!("failed to read request body: {err}"))?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.len() > 1024 * 1024 {
return Err("request too large".to_owned());
}
}
if buffer.len() < body_start + content_length {
return Err("truncated request body".to_owned());
}
let response = dispatch_http(
method,
uri,
headers,
Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]),
state,
)
.await;
write_http_response(&mut stream, response).await
}
#[cfg(test)]
async fn handle_request(
req: Request<ResponseBody>,
state: Arc<ServerState>,
) -> Response<ResponseBody> {
let (parts, body) = req.into_parts();
let body = match body.collect().await {
Ok(body) => body.to_bytes(),
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid request body: {err}"),
None,
);
}
};
dispatch_http(
parts.method,
parts.uri,
parts.headers,
body,
Arc::clone(&state),
)
.await
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> {
let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?;
let mut lines = text.split("\r\n");
let request_line = lines
.next()
.ok_or_else(|| "missing request line".to_owned())?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| "missing method".to_owned())?
.parse::<Method>()
.map_err(|_| "invalid method".to_owned())?;
let uri = request_parts
.next()
.ok_or_else(|| "missing uri".to_owned())?
.parse::<Uri>()
.map_err(|_| "invalid uri".to_owned())?;
let mut headers = HeaderMap::new();
let mut content_length = 0;
for line in lines {
if line.is_empty() {
continue;
}
let Some((name, value)) = line.split_once(':') else {
return Err("invalid header line".to_owned());
};
let name = http::header::HeaderName::from_bytes(name.trim().as_bytes())
.map_err(|_| "invalid header name".to_owned())?;
let value =
HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?;
if name == http::header::CONTENT_LENGTH {
content_length = value
.to_str()
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.ok_or_else(|| "invalid content-length".to_owned())?;
}
headers.insert(name, value);
}
Ok((method, uri, headers, content_length))
}
async fn write_http_response(
stream: &mut tokio::net::TcpStream,
response: Response<ResponseBody>,
) -> Result<(), String> {
let status = response.status();
let headers = response.headers().clone();
let body = response
.into_body()
.collect()
.await
.map_err(|err| format!("failed to collect response: {err}"))?
.to_bytes();
let reason = status.canonical_reason().unwrap_or("Unknown");
let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes();
for (name, value) in &headers {
bytes.extend_from_slice(name.as_str().as_bytes());
bytes.extend_from_slice(b": ");
bytes.extend_from_slice(value.as_bytes());
bytes.extend_from_slice(b"\r\n");
}
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
bytes.extend_from_slice(b"connection: close\r\n\r\n");
bytes.extend_from_slice(&body);
stream
.write_all(&bytes)
.await
.map_err(|err| format!("failed to write response: {err}"))
}
async fn dispatch_http(
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
state: Arc<ServerState>,
) -> Response<ResponseBody> {
let origin = match validate_request_origin(&headers, &state.config) {
Ok(origin) => origin,
Err(response) => return *response,
};
if has_forbidden_query_secret(&uri) {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"secrets in URL query strings are forbidden",
origin.as_deref(),
);
}
if method == Method::OPTIONS {
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
}
match (method, uri.path()) {
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
(Method::POST, "/api/invoke") => {
let Some(token) = session_cookie(&headers) else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
);
};
if !state.has_session(&token) {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
);
}
invoke(body, state, origin.as_deref()).await
}
(_, "/api/pair" | "/api/invoke") => error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin.as_deref(),
),
_ => error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
"route not found",
origin.as_deref(),
),
}
}
#[derive(Deserialize)]
struct PairRequest {
code: String,
}
async fn pair(
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let request = match serde_json::from_slice::<PairRequest>(&body) {
Ok(request) => request,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid pairing request: {err}"),
origin,
);
}
};
if request.code != state.pairing_code() {
return error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"invalid pairing code",
origin,
);
}
let token = state.create_session();
let cookie = Cookie::build((SESSION_COOKIE, token))
.path("/")
.http_only(true)
.secure(state.config.secure_cookie())
.same_site(SameSite::Strict)
.build();
let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin);
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"),
);
response
}
#[derive(Deserialize)]
struct InvokeRequest {
command: String,
#[serde(default)]
args: Value,
}
async fn invoke(
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let request = match serde_json::from_slice::<InvokeRequest>(&body) {
Ok(request) => request,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid invoke request: {err}"),
origin,
);
}
};
let result = match request.command.as_str() {
"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),
}),
};
match result {
Ok(value) => json_response(StatusCode::OK, &value, origin),
Err(error) => error_dto_response(status_for_error(&error), error, origin),
}
}
fn invoke_health(args: &Value, state: &AppState) -> Result<Value, ErrorDto> {
let request = optional_request::<HealthRequestDto>(args)?;
let output = state
.health
.execute(request.unwrap_or_default().into())
.map(HealthResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_projects(state: &AppState) -> Result<Value, ErrorDto> {
let output = state
.list_projects
.execute()
.await
.map(ProjectListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_open_project(args: &Value, state: &AppState) -> Result<Value, ErrorDto> {
let project_id = args
.get("projectId")
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "open_project requires args.projectId".to_owned(),
})?;
let project = resolve_project_readonly(project_id, state).await?;
serde_json::to_value(ProjectDto::from(project)).map_err(serialization_error)
}
async fn invoke_get_project_work_state(args: &Value, state: &AppState) -> Result<Value, ErrorDto> {
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<Project, ErrorDto> {
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<T>(args: &Value) -> Result<Option<T>, ErrorDto>
where
T: for<'de> Deserialize<'de>,
{
match args {
Value::Object(map) => match map.get("request") {
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(invalid_args_error),
None if map.is_empty() => Ok(None),
None => serde_json::from_value(args.clone())
.map(Some)
.map_err(invalid_args_error),
},
Value::Null => Ok(None),
_ => Err(ErrorDto {
code: "INVALID".to_owned(),
message: "args must be an object".to_owned(),
}),
}
}
fn validate_request_origin(
headers: &HeaderMap,
config: &ServerConfig,
) -> Result<Option<String>, Box<Response<ResponseBody>>> {
let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else {
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"missing Origin header",
None,
)));
};
if origin_allowed(origin, config) {
Ok(Some(origin.to_owned()))
} else {
Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"origin not allowed",
None,
)))
}
}
fn origin_allowed(origin: &str, config: &ServerConfig) -> bool {
if let Some(public_origin) = &config.public_origin {
return origin == public_origin;
}
if !config.listen.ip().is_loopback() {
return false;
}
let port = config.listen.port();
origin == format!("http://127.0.0.1:{port}")
|| origin == format!("http://localhost:{port}")
|| origin == format!("http://[::1]:{port}")
}
fn has_forbidden_query_secret(uri: &Uri) -> bool {
uri.query().is_some_and(|query| {
query.split('&').any(|pair| {
let key = pair.split_once('=').map_or(pair, |(key, _)| key);
matches!(
key.to_ascii_lowercase().as_str(),
"token" | "secret" | "session" | "code"
)
})
})
}
fn session_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|raw| {
raw.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == SESSION_COOKIE).then(|| value.to_owned())
})
})
}
fn json_response(
status: StatusCode,
value: &Value,
origin: Option<&str>,
) -> Response<ResponseBody> {
let body = serde_json::to_vec(value).expect("JSON value serializes");
let mut response = Response::new(Full::new(Bytes::from(body)));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
add_cors_headers(&mut response, origin);
response
}
fn error_dto_response(
status: StatusCode,
error: ErrorDto,
origin: Option<&str>,
) -> Response<ResponseBody> {
let value = serde_json::to_value(error).expect("ErrorDto serializes");
json_response(status, &value, origin)
}
fn error_response(
status: StatusCode,
code: impl Into<String>,
message: impl Into<String>,
origin: Option<&str>,
) -> Response<ResponseBody> {
error_dto_response(
status,
ErrorDto {
code: code.into(),
message: message.into(),
},
origin,
)
}
fn cors_response(status: StatusCode, origin: Option<&str>) -> Response<ResponseBody> {
let mut response = Response::new(Full::new(Bytes::new()));
*response.status_mut() = status;
add_cors_headers(&mut response, origin);
response
}
fn add_cors_headers(response: &mut Response<ResponseBody>, origin: Option<&str>) {
if let Some(origin) = origin {
if let Ok(origin) = HeaderValue::from_str(origin) {
response
.headers_mut()
.insert("access-control-allow-origin", origin);
response.headers_mut().insert(
"access-control-allow-credentials",
HeaderValue::from_static("true"),
);
response.headers_mut().insert(
"access-control-allow-headers",
HeaderValue::from_static("content-type"),
);
response.headers_mut().insert(
"access-control-allow-methods",
HeaderValue::from_static("POST, OPTIONS"),
);
}
}
}
fn status_for_error(error: &ErrorDto) -> StatusCode {
match error.code.as_str() {
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
"INVALID" => StatusCode::BAD_REQUEST,
"NOT_FOUND" => StatusCode::NOT_FOUND,
"FORBIDDEN" => StatusCode::FORBIDDEN,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn validate_origin(origin: &str) -> Result<(), String> {
if origin == "*" || origin.contains('*') {
return Err("--public-origin must be exact, not a wildcard".to_owned());
}
if origin.ends_with('/') {
return Err("--public-origin must not end with '/'".to_owned());
}
if !(origin.starts_with("https://") || origin.starts_with("http://")) {
return Err("--public-origin must be an HTTP(S) origin".to_owned());
}
Ok(())
}
fn default_app_data_dir() -> PathBuf {
if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") {
return PathBuf::from(path);
}
if let Some(path) = env::var_os("XDG_DATA_HOME") {
return PathBuf::from(path).join("IdeA");
}
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join(".local/share/IdeA");
}
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".ideai/app-data")
}
fn new_pairing_code() -> String {
Uuid::new_v4()
.simple()
.to_string()
.chars()
.take(8)
.collect::<String>()
.to_ascii_uppercase()
}
fn new_session_token() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
fn invalid_args_error(err: serde_json::Error) -> ErrorDto {
ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid command args: {err}"),
}
}
fn serialization_error(err: serde_json::Error) -> ErrorDto {
ErrorDto {
code: "SERIALIZATION".to_owned(),
message: format!("failed to serialize response: {err}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use application::CreateProjectInput;
use http::header::HeaderName;
fn test_config() -> ServerConfig {
ServerConfig {
listen: "127.0.0.1:17373".parse().unwrap(),
public_origin: None,
allow_remote: false,
trust_reverse_proxy: false,
app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())),
}
}
fn state() -> Arc<ServerState> {
Arc::new(ServerState::new_for_test(test_config(), "PAIR1234"))
}
async fn request(
state: Arc<ServerState>,
method: Method,
uri: &str,
body: Value,
extra_headers: &[(&str, &str)],
) -> Response<ResponseBody> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(ORIGIN, "http://127.0.0.1:17373")
.header(CONTENT_TYPE, "application/json");
for (name, value) in extra_headers {
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
}
handle_request(
builder
.body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
.unwrap(),
state,
)
.await
}
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body().collect().await.unwrap().to_bytes();
let value = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).unwrap()
};
(status, value, headers)
}
async fn pair_and_cookie(state: Arc<ServerState>) -> String {
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234" }),
&[],
)
.await;
response
.headers()
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn create_project_for_test(state: &Arc<ServerState>, 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 {
listen: "0.0.0.0:17373".parse().unwrap(),
..test_config()
};
assert!(config.validate().is_err());
let config = ServerConfig {
allow_remote: true,
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: true,
..config
};
assert!(config.validate().is_ok());
}
#[test]
fn allow_remote_requires_https_origin_and_reverse_proxy() {
let base = ServerConfig {
listen: "0.0.0.0:17373".parse().unwrap(),
allow_remote: true,
..test_config()
};
assert!(base.validate().is_err());
let with_http = ServerConfig {
public_origin: Some("http://idea.example.com".to_owned()),
trust_reverse_proxy: true,
..base.clone()
};
assert!(with_http.validate().is_err());
let without_proxy = ServerConfig {
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: false,
..base
};
assert!(without_proxy.validate().is_err());
}
#[tokio::test]
async fn invoke_requires_valid_cookie() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn invoke_rejects_invalid_session_cookie() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[("cookie", "idea_session=not-a-valid-session")],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn pairing_rejects_wrong_code() {
let state = state();
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "WRONG999" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
assert!(
!headers.contains_key(SET_COOKIE),
"wrong pairing code must not issue a session cookie"
);
}
#[tokio::test]
async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() {
let state = state();
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "paired": true }));
let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap();
assert!(cookie.contains("idea_session="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
assert!(
!cookie.contains("Secure"),
"loopback dev over HTTP deliberately avoids Secure so browsers store it"
);
}
#[tokio::test]
async fn pairing_sets_secure_cookie_for_remote_https_origin() {
let config = ServerConfig {
listen: "0.0.0.0:17373".parse().unwrap(),
public_origin: Some("https://idea.example.com".to_owned()),
allow_remote: true,
trust_reverse_proxy: true,
..test_config()
};
let state = Arc::new(ServerState::new_for_test(config, "PAIR1234"));
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, "https://idea.example.com")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#)))
.unwrap();
*req.headers_mut().get_mut(CONTENT_TYPE).unwrap() =
HeaderValue::from_static("application/json");
let response = handle_request(req, state).await;
let (status, _, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap();
assert!(cookie.contains("Secure"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
}
#[tokio::test]
async fn token_or_secret_in_query_string_is_refused() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke?token=abc",
json!({ "command": "health", "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
}
#[tokio::test]
async fn non_allowed_origin_is_refused() {
let state = state();
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, "https://evil.example")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(br#"{"code":"PAIR1234"}"#)))
.unwrap();
*req.headers_mut().get_mut(CONTENT_TYPE).unwrap() =
HeaderValue::from_static("application/json");
let response = handle_request(req, state).await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
#[tokio::test]
async fn unknown_allowlisted_command_returns_unknown_command() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "debug_dump", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "UNKNOWN_COMMAND");
}
#[tokio::test]
async fn authorized_health_invoke_returns_health_report() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": { "request": { "note": "hi" } } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["alive"], true);
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();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": "nope" } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
}
#[test]
fn direct_project_id_parser_still_rejects_invalid_ids() {
assert!(parse_project_id("not-a-uuid").is_err());
}
}