fix: preserve backend product authority boundaries

This commit is contained in:
2026-08-22 20:03:27 +09:00
parent 779ced82b1
commit 9dc6d8f144
3 changed files with 99 additions and 18 deletions
+38 -2
View File
@@ -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![
+6 -6
View File
@@ -3672,13 +3672,10 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority {
}
fn browser_ticket_backend(api: &WorkspaceApi) -> Result<SqliteTicketBackend> {
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<TicketBackendOperationResult> {
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()
+55 -10
View File
@@ -214,22 +214,15 @@ struct TargetSelection {
workspace_id: Option<String>,
}
impl TargetSelection {
fn explicit_backend(&self) -> bool {
self.backend_url.is_some()
}
}
fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
connection_resolver: &R,
command: CliCommand,
selection: &TargetSelection,
workspace_root: &Path,
) -> Result<Box<dyn Target>, 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<Box<dyn Target>, 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() {