443 lines
15 KiB
Rust
443 lines
15 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use ::local_control::auth::{CredentialGrant, CredentialRequest};
|
|
use ::local_control::protocol::{
|
|
Action, ActionKind, PaneSelector, PaneTarget, TabSelector, TabTarget, TargetSelector,
|
|
WindowSelector, WindowTarget,
|
|
};
|
|
use ::local_control::{ErrorCode, InstanceId, RequestEnvelope};
|
|
use axum::body::Bytes;
|
|
use axum::extract::State;
|
|
use axum::http::header::{AUTHORIZATION, HOST, ORIGIN};
|
|
use axum::http::{HeaderMap, HeaderValue};
|
|
use chrono::Duration;
|
|
use galaxy_core::features::FeatureFlag;
|
|
use settings::Setting as _;
|
|
use warpui::SingletonEntity as _;
|
|
|
|
#[cfg(unix)]
|
|
use super::ensure_peer_uid;
|
|
use super::resolver::validate_action_target;
|
|
use super::{
|
|
capabilities, ensure_feature_enabled, ensure_protocol_version, ensure_settings_allow_action,
|
|
handle_control_request, insert_credential, issue_credential, lookup_credential,
|
|
require_active_window_id, resolve_index_from_ids, resolve_title_from_matches,
|
|
validate_action_params, validate_loopback_headers, validate_request_authority,
|
|
validate_tab_create_target, ControlServerState, LocalControlBridge, LocalControlServer,
|
|
MAX_ACTIVE_CREDENTIALS,
|
|
};
|
|
use crate::settings::{LocalControlMode, LocalControlModeSetting, LocalControlSettings};
|
|
|
|
fn settings_with_mode(mode: LocalControlMode) -> LocalControlSettings {
|
|
LocalControlSettings {
|
|
local_control_mode: LocalControlModeSetting::new(Some(mode)),
|
|
}
|
|
}
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn credential_broker_rejects_peer_from_different_user() {
|
|
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
|
|
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
|
|
let different_uid = if actual_uid == u32::MAX {
|
|
actual_uid - 1
|
|
} else {
|
|
actual_uid + 1
|
|
};
|
|
|
|
let err = ensure_peer_uid(&stream, different_uid).expect_err("different user is rejected");
|
|
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
|
}
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn credential_broker_accepts_peer_from_same_user() {
|
|
let (stream, _peer) = tokio::net::UnixStream::pair().expect("socket pair");
|
|
let actual_uid = stream.peer_cred().expect("peer credentials").uid();
|
|
|
|
ensure_peer_uid(&stream, actual_uid).expect("same user is accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn protocol_version_helper_rejects_unsupported_versions() {
|
|
ensure_protocol_version(::local_control::PROTOCOL_VERSION)
|
|
.expect("current version is accepted");
|
|
|
|
let err = ensure_protocol_version(::local_control::PROTOCOL_VERSION + 1)
|
|
.expect_err("future protocol version is rejected");
|
|
assert_eq!(err.code, ErrorCode::ProtocolVersionUnsupported);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_create_accepts_default_and_window_targets() {
|
|
validate_tab_create_target(&TargetSelector::default()).expect("default target is accepted");
|
|
|
|
validate_tab_create_target(&TargetSelector {
|
|
window: Some(WindowTarget::Id {
|
|
id: WindowSelector("window".to_owned()),
|
|
}),
|
|
tab: None,
|
|
pane: None,
|
|
session: None,
|
|
})
|
|
.expect("window id target is accepted");
|
|
|
|
validate_tab_create_target(&TargetSelector {
|
|
window: Some(WindowTarget::Index { index: 0 }),
|
|
tab: None,
|
|
pane: None,
|
|
session: None,
|
|
})
|
|
.expect("window index target is accepted");
|
|
|
|
validate_tab_create_target(&TargetSelector {
|
|
window: Some(WindowTarget::Title {
|
|
title: "window".to_owned(),
|
|
}),
|
|
tab: None,
|
|
pane: None,
|
|
session: None,
|
|
})
|
|
.expect("window title target is accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn tab_create_rejects_lower_level_targets() {
|
|
let err = validate_tab_create_target(&TargetSelector {
|
|
window: None,
|
|
tab: Some(TabTarget::Id {
|
|
id: TabSelector("tab".to_owned()),
|
|
}),
|
|
pane: None,
|
|
session: None,
|
|
})
|
|
.expect_err("concrete tab target is rejected");
|
|
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
|
|
|
let err = validate_tab_create_target(&TargetSelector {
|
|
window: None,
|
|
tab: None,
|
|
pane: Some(PaneTarget::Id {
|
|
id: PaneSelector("pane".to_owned()),
|
|
}),
|
|
session: None,
|
|
})
|
|
.expect_err("concrete pane target is rejected");
|
|
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_create_rejects_unsupported_selector_forms() {
|
|
let err = validate_tab_create_target(&TargetSelector {
|
|
window: None,
|
|
tab: Some(TabTarget::Index { index: 0 }),
|
|
pane: None,
|
|
session: None,
|
|
})
|
|
.expect_err("indexed tab target is rejected");
|
|
assert_eq!(err.code, ErrorCode::InvalidSelector);
|
|
}
|
|
|
|
#[test]
|
|
fn surface_list_rejects_target_selectors() {
|
|
let error = validate_action_target(
|
|
ActionKind::SurfaceList,
|
|
&TargetSelector {
|
|
window: Some(WindowTarget::Active),
|
|
tab: None,
|
|
pane: None,
|
|
session: None,
|
|
},
|
|
)
|
|
.expect_err("surface.list is instance-wide");
|
|
assert_eq!(error.code, ErrorCode::InvalidSelector);
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_advertises_the_complete_catalog() {
|
|
assert_eq!(capabilities().len(), 80);
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_headers_reject_origin_and_host_mismatch() {
|
|
let expected_host = "127.0.0.1:1234";
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(HOST, HeaderValue::from_static(expected_host));
|
|
|
|
validate_loopback_headers(&headers, expected_host).expect("matching host should be accepted");
|
|
|
|
headers.insert(ORIGIN, HeaderValue::from_static("https://example.com"));
|
|
let err =
|
|
validate_loopback_headers(&headers, expected_host).expect_err("origin should be rejected");
|
|
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
|
|
|
headers.remove(ORIGIN);
|
|
headers.insert(HOST, HeaderValue::from_static("localhost:1234"));
|
|
let err = validate_loopback_headers(&headers, expected_host)
|
|
.expect_err("host mismatch should be rejected");
|
|
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
|
|
|
let headers = HeaderMap::new();
|
|
let err = validate_loopback_headers(&headers, expected_host)
|
|
.expect_err("missing host should be rejected");
|
|
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
|
}
|
|
|
|
#[test]
|
|
fn scripting_mode_controls_local_control() {
|
|
assert!(!settings_with_mode(LocalControlMode::Disabled).is_enabled());
|
|
assert!(settings_with_mode(LocalControlMode::Enabled).is_enabled());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_create_requires_active_window() {
|
|
let active = warpui::WindowId::from_usize(1);
|
|
|
|
assert_eq!(
|
|
require_active_window_id(Some(active)).expect("active"),
|
|
active
|
|
);
|
|
let err = require_active_window_id(None).expect_err("missing active window");
|
|
assert_eq!(err.code, ErrorCode::MissingTarget);
|
|
}
|
|
|
|
#[test]
|
|
fn window_title_resolution_distinguishes_missing_and_ambiguous_targets() {
|
|
let missing = resolve_title_from_matches(&[], ActionKind::TabCreate)
|
|
.expect_err("zero-match title is missing");
|
|
assert_eq!(missing.code, ErrorCode::MissingTarget);
|
|
|
|
let matches = [
|
|
warpui::WindowId::from_usize(1),
|
|
warpui::WindowId::from_usize(2),
|
|
];
|
|
let ambiguous = resolve_title_from_matches(&matches, ActionKind::TabCreate)
|
|
.expect_err("multi-match title is ambiguous");
|
|
assert_eq!(ambiguous.code, ErrorCode::AmbiguousTarget);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_window_index_returns_missing_target() {
|
|
let err = resolve_index_from_ids(std::iter::empty(), 0, ActionKind::TabCreate)
|
|
.expect_err("zero-match index is missing");
|
|
assert_eq!(err.code, ErrorCode::MissingTarget);
|
|
}
|
|
|
|
#[test]
|
|
fn feature_flag_disabled_denies_local_control() {
|
|
let _flag = FeatureFlag::GalaxyControlCli.override_enabled(false);
|
|
let err = ensure_feature_enabled().expect_err("feature flag disabled");
|
|
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
|
}
|
|
#[test]
|
|
fn duplicate_server_start_is_rejected() {
|
|
warpui::App::test((), |mut app| async move {
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.build()
|
|
.expect("runtime");
|
|
let server = app.add_model(|_| LocalControlServer {
|
|
_runtime: Some(runtime),
|
|
control_endpoint: None,
|
|
registered_instance: None,
|
|
});
|
|
|
|
let err = server
|
|
.update(&mut app, |server, ctx| server.start(ctx))
|
|
.expect_err("duplicate start should fail");
|
|
assert_eq!(err.code, ErrorCode::Internal);
|
|
|
|
server
|
|
.update(&mut app, |server, _| server._runtime.take())
|
|
.expect("existing runtime should remain active")
|
|
.shutdown_background();
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn scripting_disabled_denies_action() {
|
|
let settings = settings_with_mode(LocalControlMode::Disabled);
|
|
|
|
let err = ensure_settings_allow_action(&settings, ActionKind::TabCreate)
|
|
.expect_err("disabled scripting denies action");
|
|
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
|
}
|
|
|
|
#[test]
|
|
fn scripting_enabled_allows_action() {
|
|
ensure_settings_allow_action(
|
|
&settings_with_mode(LocalControlMode::Enabled),
|
|
ActionKind::TabCreate,
|
|
)
|
|
.expect("enabled scripting allows action");
|
|
}
|
|
|
|
#[test]
|
|
fn tab_create_rejects_malformed_params() {
|
|
let err = validate_action_params(&Action {
|
|
kind: ActionKind::TabCreate,
|
|
params: serde_json::json!({ "unexpected": true }),
|
|
})
|
|
.expect_err("tab.create params must be empty");
|
|
assert_eq!(err.code, ErrorCode::InvalidParams);
|
|
|
|
validate_action_params(&Action {
|
|
kind: ActionKind::TabCreate,
|
|
params: serde_json::json!({}),
|
|
})
|
|
.expect("empty tab.create params are accepted");
|
|
}
|
|
|
|
#[test]
|
|
fn metadata_actions_reject_malformed_params() {
|
|
let err = validate_action_params(&Action {
|
|
kind: ActionKind::AppPing,
|
|
params: serde_json::json!({ "unexpected": true }),
|
|
})
|
|
.expect_err("app.ping params must be empty");
|
|
assert_eq!(err.code, ErrorCode::InvalidParams);
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_checks_grant_before_action_params() {
|
|
let instance_id = InstanceId("inst_test".to_owned());
|
|
let grant = CredentialGrant::new(
|
|
instance_id.clone(),
|
|
ActionKind::AppPing,
|
|
Duration::minutes(5),
|
|
);
|
|
let err = validate_request_authority(
|
|
&instance_id,
|
|
&Action {
|
|
kind: ActionKind::AppVersion,
|
|
params: serde_json::json!({ "unexpected": true }),
|
|
},
|
|
&grant,
|
|
)
|
|
.expect_err("wrong-action grant is rejected before params");
|
|
assert_eq!(err.code, ErrorCode::InsufficientPermissions);
|
|
}
|
|
|
|
#[test]
|
|
fn credential_insertion_prunes_expired_and_caps_active_grants() {
|
|
let mut credentials = HashMap::new();
|
|
let instance_id = InstanceId("inst_test".to_owned());
|
|
insert_credential(
|
|
&mut credentials,
|
|
"expired".to_owned(),
|
|
CredentialGrant::new(
|
|
instance_id.clone(),
|
|
ActionKind::TabCreate,
|
|
Duration::minutes(-1),
|
|
),
|
|
);
|
|
insert_credential(
|
|
&mut credentials,
|
|
"active".to_owned(),
|
|
CredentialGrant::new(
|
|
instance_id.clone(),
|
|
ActionKind::TabCreate,
|
|
Duration::minutes(5),
|
|
),
|
|
);
|
|
assert!(!credentials.contains_key("expired"));
|
|
|
|
for index in 0..MAX_ACTIVE_CREDENTIALS {
|
|
insert_credential(
|
|
&mut credentials,
|
|
format!("active-{index}"),
|
|
CredentialGrant::new(
|
|
instance_id.clone(),
|
|
ActionKind::TabCreate,
|
|
Duration::minutes(5),
|
|
),
|
|
);
|
|
}
|
|
assert_eq!(credentials.len(), MAX_ACTIVE_CREDENTIALS);
|
|
assert!(credentials.contains_key(&format!("active-{}", MAX_ACTIVE_CREDENTIALS - 1)));
|
|
}
|
|
|
|
#[test]
|
|
fn expired_credential_is_rejected_and_pruned_before_request_decode() {
|
|
let mut credentials = HashMap::new();
|
|
let token = ::local_control::AuthToken::from_secret("expired");
|
|
credentials.insert(
|
|
token.secret().to_owned(),
|
|
CredentialGrant::new(
|
|
InstanceId("inst_test".to_owned()),
|
|
ActionKind::TabCreate,
|
|
Duration::minutes(-1),
|
|
),
|
|
);
|
|
|
|
let err = lookup_credential(
|
|
&mut credentials,
|
|
&token,
|
|
&InstanceId("inst_test".to_owned()),
|
|
)
|
|
.expect_err("expired grant is rejected");
|
|
assert_eq!(err.code, ErrorCode::UnauthorizedLocalClient);
|
|
assert!(!credentials.contains_key(token.secret()));
|
|
}
|
|
|
|
#[test]
|
|
fn disabling_scripting_invalidates_existing_grant_and_prevents_new_grants() {
|
|
let _flag = FeatureFlag::GalaxyControlCli.override_enabled(true);
|
|
warpui::App::test((), |mut app| async move {
|
|
crate::test_util::settings::initialize_settings_for_tests(&mut app);
|
|
app.update(|ctx| {
|
|
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
settings
|
|
.local_control_mode
|
|
.set_value(LocalControlMode::Enabled, ctx)
|
|
})
|
|
})
|
|
.expect("local control should enable");
|
|
|
|
let instance_id = InstanceId("inst_test".to_owned());
|
|
let expected_host = "127.0.0.1:1234".to_owned();
|
|
let bridge = app.add_singleton_model(LocalControlBridge::new);
|
|
let state = bridge.update(&mut app, |bridge, ctx| {
|
|
bridge.set_instance_id(instance_id.clone());
|
|
ControlServerState {
|
|
bridge_spawner: ctx.spawner(),
|
|
instance_id: instance_id.clone(),
|
|
expected_host: expected_host.clone(),
|
|
credentials: Default::default(),
|
|
}
|
|
});
|
|
let credential = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
|
|
.await
|
|
.expect("local-control credential should be issued");
|
|
|
|
app.update(|ctx| {
|
|
LocalControlSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
settings
|
|
.local_control_mode
|
|
.set_value(LocalControlMode::Disabled, ctx)
|
|
})
|
|
})
|
|
.expect("local control should disable");
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
HOST,
|
|
HeaderValue::from_str(&expected_host).expect("valid host"),
|
|
);
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
HeaderValue::from_str(&credential.authorization_value()).expect("valid credential"),
|
|
);
|
|
let request = RequestEnvelope::new(Action::new(ActionKind::AppPing));
|
|
let response = handle_control_request(
|
|
State(state.clone()),
|
|
headers,
|
|
Bytes::from(serde_json::to_vec(&request).expect("request serializes")),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST);
|
|
|
|
let err = issue_credential(&state, CredentialRequest::new(ActionKind::AppPing))
|
|
.await
|
|
.expect_err("disabled scripting should prevent new grants");
|
|
assert_eq!(err.code, ErrorCode::LocalControlDisabled);
|
|
});
|
|
}
|