Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,13 @@ async-trait = "0.1"

# Slack
slack-morphism = { version = "2.17", features = ["hyper"] }
emojis = "0.8"

# TLS (shared crypto backend for slack-morphism, reqwest, teloxide)
rustls = { version = "0.23", default-features = false, features = ["ring"] }
# slack-morphism enables tokio-tungstenite/rustls-native-certs, but tokio-tungstenite 0.28
# restructured features — rustls-tls-native-roots is now needed to activate actual TLS.
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-native-roots"] }

# Telegram
teloxide = { version = "0.17", default-features = false, features = ["rustls"] }
Expand Down
14 changes: 11 additions & 3 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ use super::{
};

use axum::Json;
use axum::extract::Request;
use axum::Router;
use axum::extract::{Request, State};
use axum::http::{StatusCode, Uri, header};
use axum::middleware::{self, Next};
use axum::response::{Html, IntoResponse, Response};
Expand Down Expand Up @@ -177,7 +177,11 @@ pub async fn start_http_server(
Ok(handle)
}

async fn api_auth_middleware(state: Arc<ApiState>, request: Request, next: Next) -> Response {
async fn api_auth_middleware(
State(state): State<Arc<ApiState>>,
request: Request,
next: Next,
) -> Response {
let Some(expected_token) = state.auth_token.as_deref() else {
return next.run(request).await;
};
Expand All @@ -197,7 +201,11 @@ async fn api_auth_middleware(state: Arc<ApiState>, request: Request, next: Next)
if is_authorized {
next.run(request).await
} else {
(StatusCode::UNAUTHORIZED, Json(json!({"error": "unauthorized"}))).into_response()
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": "unauthorized"})),
)
.into_response()
}
}

Expand Down
110 changes: 109 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,15 @@ impl SlackPermissions {
filter
};

let dm_allowed_users = slack.dm_allowed_users.clone();
let mut dm_allowed_users = slack.dm_allowed_users.clone();

for binding in &slack_bindings {
for id in &binding.dm_allowed_users {
if !dm_allowed_users.contains(id) {
dm_allowed_users.push(id.clone());
}
}
}

Self {
workspace_filter,
Expand Down Expand Up @@ -3985,4 +3993,104 @@ bind = "127.0.0.1"

assert_eq!(config.api.bind, "[::]");
}


/// Helper to build a minimal `SlackConfig` for permission tests.
fn slack_config_with_dm_users(dm_allowed_users: Vec<String>) -> SlackConfig {
SlackConfig {
enabled: true,
bot_token: "xoxb-test".into(),
app_token: "xapp-test".into(),
dm_allowed_users,
commands: vec![],
}
}

/// Helper to build a Slack binding with optional dm_allowed_users.
fn slack_binding(workspace_id: Option<&str>, dm_allowed_users: Vec<String>) -> Binding {
Binding {
agent_id: "test-agent".into(),
channel: "slack".into(),
guild_id: None,
workspace_id: workspace_id.map(String::from),
chat_id: None,
channel_ids: vec![],
require_mention: false,
dm_allowed_users,
}
}

#[test]
fn slack_permissions_merges_dm_users_from_config_and_bindings() {
let config = slack_config_with_dm_users(vec!["U001".into(), "U002".into()]);
let bindings = vec![slack_binding(
Some("T1"),
vec!["U003".into(), "U004".into()],
)];
let perms = SlackPermissions::from_config(&config, &bindings);
assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003", "U004"]);
}

#[test]
fn slack_permissions_deduplicates_dm_users() {
let config = slack_config_with_dm_users(vec!["U001".into(), "U002".into()]);
let bindings = vec![slack_binding(
Some("T1"),
vec!["U002".into(), "U003".into()],
)];
let perms = SlackPermissions::from_config(&config, &bindings);
// U002 appears in both config and binding — should appear only once
assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003"]);
}

#[test]
fn slack_permissions_empty_dm_users_stays_empty() {
let config = slack_config_with_dm_users(vec![]);
let bindings = vec![slack_binding(Some("T1"), vec![])];
let perms = SlackPermissions::from_config(&config, &bindings);
assert!(perms.dm_allowed_users.is_empty());
}

#[test]
fn slack_permissions_merges_dm_users_from_multiple_bindings() {
let config = slack_config_with_dm_users(vec!["U001".into()]);
let bindings = vec![
slack_binding(Some("T1"), vec!["U002".into()]),
slack_binding(Some("T2"), vec!["U003".into()]),
];
let perms = SlackPermissions::from_config(&config, &bindings);
assert_eq!(perms.dm_allowed_users, vec!["U001", "U002", "U003"]);
}

#[test]
fn slack_permissions_ignores_non_slack_bindings() {
let config = slack_config_with_dm_users(vec!["U001".into()]);
let mut discord_binding = slack_binding(Some("T1"), vec!["U099".into()]);
discord_binding.channel = "discord".into();
let perms = SlackPermissions::from_config(&config, &[discord_binding]);
// U099 should not appear — that binding is for discord, not slack
assert_eq!(perms.dm_allowed_users, vec!["U001"]);
}

#[test]
fn slack_permissions_workspace_filter_from_bindings() {
let config = slack_config_with_dm_users(vec![]);
let bindings = vec![
slack_binding(Some("T1"), vec![]),
slack_binding(Some("T2"), vec![]),
];
let perms = SlackPermissions::from_config(&config, &bindings);
assert_eq!(
perms.workspace_filter,
Some(vec!["T1".to_string(), "T2".to_string()])
);
}

#[test]
fn slack_permissions_no_workspace_filter_when_none_specified() {
let config = slack_config_with_dm_users(vec![]);
let bindings = vec![slack_binding(None, vec![])];
let perms = SlackPermissions::from_config(&config, &bindings);
assert!(perms.workspace_filter.is_none());
}
}
Loading
Loading