From 9dc6d8f144b4949513e805a6a54e1a2e6e9a71ce Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 22 Aug 2026 20:03:27 +0900 Subject: [PATCH] fix: preserve backend product authority boundaries --- crates/client/src/workspace_product.rs | 40 +++++++++++++++- crates/workspace-server/src/server.rs | 12 ++--- crates/yoi/src/main.rs | 65 ++++++++++++++++++++++---- 3 files changed, 99 insertions(+), 18 deletions(-) diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index d5dff296..8909697f 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -173,7 +173,7 @@ impl BackendWorkspaceProductClient { } self.send_json( Method::POST, - "/tickets/relations/search", + "/ticket-relations/query", Some(&Query { ticket, kind }), ) } @@ -568,7 +568,7 @@ impl TicketBackend for BackendWorkspaceProductClient { } self.send_json( Method::POST, - "/tickets/orchestration-plans/search", + "/ticket-orchestration-plans/query", Some(&Query { ticket, kind }), ) .map_err(ticket_client_error) @@ -731,6 +731,42 @@ mod tests { handle.join().unwrap(); } + #[test] + fn ticket_relation_query_uses_workspace_scoped_backend_route() { + let (base_url, request, handle) = one_response_server("200 OK", "[]"); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let relations = client + .query_ticket_relations( + Some(&TicketIdOrSlug::Query("T-1".to_string())), + Some(TicketRelationKind::Related), + ) + .unwrap(); + + assert!(relations.is_empty()); + let request = request.recv().unwrap(); + assert!(request.starts_with("POST /api/w/workspace-a/ticket-relations/query ")); + assert!(request.contains("\"ticket\":{\"Query\":\"T-1\"}")); + handle.join().unwrap(); + } + + #[test] + fn orchestration_plan_query_uses_workspace_scoped_backend_route() { + let (base_url, request, handle) = one_response_server("200 OK", "[]"); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap(); + + assert!(records.is_empty()); + assert!( + request + .recv() + .unwrap() + .starts_with("POST /api/w/workspace-a/ticket-orchestration-plans/query ") + ); + handle.join().unwrap(); + } + #[test] fn ticket_intake_launch_uses_backend_options_and_workspace_worker_route() { let (base_url, requests, handle) = response_sequence_server(vec![ diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 117fb040..0ec17c04 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3672,13 +3672,10 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority { } fn browser_ticket_backend(api: &WorkspaceApi) -> Result { - let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) - .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; Ok(SqliteTicketBackend::open_verified( api.config.database_path.clone(), api.config.workspace_id.clone(), )? - .with_record_language(config.ticket_record_language()) .with_target_authority(Arc::new(WorkspaceTicketTargetAuthority { api: api.clone(), }))) @@ -3849,14 +3846,11 @@ async fn execute_ticket_rest_operation( mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; - let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) - .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), api.config.workspace_id.clone(), ) .map_err(Error::from)? - .with_record_language(config.ticket_record_language()) .with_target_authority(Arc::new(WorkspaceTicketTargetAuthority { api: api.clone(), })); @@ -16285,6 +16279,12 @@ mod tests { async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/workspace.toml"), + "this is not valid workspace config", + ) + .unwrap(); let api = test_api(dir.path()).await; let ticket_ref = browser_ticket_backend(&api) .unwrap() diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 2a5e119f..c2f10655 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -214,22 +214,15 @@ struct TargetSelection { workspace_id: Option, } -impl TargetSelection { - fn explicit_backend(&self) -> bool { - self.backend_url.is_some() - } -} - fn resolve_tui_target( connection_resolver: &R, command: CliCommand, selection: &TargetSelection, workspace_root: &Path, ) -> Result, ParseError> { - let workspace_id = if selection.explicit_backend() && selection.workspace_id.is_none() { - resolve_workspace_id_from_root(workspace_root)? - } else { - selection.workspace_id.clone() + let workspace_id = match selection.workspace_id.clone() { + Some(workspace_id) => Some(workspace_id), + None => resolve_workspace_id_from_root(workspace_root)?, }; resolve_connection_aware_cli_connection( connection_resolver, @@ -1755,6 +1748,28 @@ mod tests { } } + struct DefaultBackendCliConnectionResolver { + backend_url: &'static str, + } + + impl CliConnectionResolver for DefaultBackendCliConnectionResolver { + fn resolve_connection( + &self, + _command: CliCommand, + input: CliConnectionInput<'_>, + ) -> Result, ParseError> { + let workspace_id = match input { + CliConnectionInput::DefaultTarget { workspace_id } + | CliConnectionInput::BackendTarget { workspace_id, .. } => workspace_id, + CliConnectionInput::LocalTarget => return Ok(Box::new(LocalTarget::new())), + }; + Ok(Box::new(BackendTarget::new( + self.backend_url, + workspace_id.map(str::to_string), + ))) + } + } + #[test] fn parser_uses_local_target_for_workers_without_backend_option() { let resolver = FixedCliConnectionResolver { @@ -2136,6 +2151,36 @@ backend = "shared" } } + #[test] + fn default_backend_target_inherits_workspace_identity_from_workspace_root() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap(); + std::fs::write( + workspace.path().join(".yoi/workspace.toml"), + "workspace_id = \"workspace-from-root\"\n", + ) + .unwrap(); + let resolver = DefaultBackendCliConnectionResolver { + backend_url: "http://default-backend.example", + }; + + let target = resolve_tui_target( + &resolver, + CliCommand::Ticket, + &TargetSelection::default(), + workspace.path(), + ) + .unwrap(); + + assert_eq!( + target.resolve().unwrap(), + client::ResolvedTarget::Backend { + base_url: "http://default-backend.example".to_string(), + workspace_id: "workspace-from-root".to_string(), + } + ); + } + #[test] fn parse_ticket_subcommand_uses_ticket_mode() { match parse_args_from(["ticket", "doctor"]).unwrap() {