Skip to main content

slack_rs/auth/
manifest.rs

1//! Slack App Manifest generation
2//!
3//! This module provides functionality to generate Slack App Manifest YAML files
4//! from OAuth configuration and scope information.
5
6use serde::{Deserialize, Serialize};
7
8/// Slack App Manifest structure
9#[derive(Debug, Serialize, Deserialize)]
10pub struct AppManifest {
11    #[serde(rename = "_metadata")]
12    pub _metadata: Metadata,
13    pub display_information: DisplayInformation,
14    pub features: Features,
15    pub oauth_config: OAuthConfig,
16    pub settings: Settings,
17}
18
19#[derive(Debug, Serialize, Deserialize)]
20pub struct Metadata {
21    pub major_version: u32,
22    pub minor_version: u32,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct DisplayInformation {
27    pub name: String,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub description: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub background_color: Option<String>,
32}
33
34#[derive(Debug, Serialize, Deserialize)]
35pub struct Features {
36    pub bot_user: BotUser,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40pub struct BotUser {
41    pub display_name: String,
42    pub always_online: bool,
43}
44
45#[derive(Debug, Serialize, Deserialize)]
46pub struct OAuthConfig {
47    pub redirect_urls: Vec<String>,
48    pub scopes: Scopes,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52pub struct Scopes {
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub bot: Option<Vec<String>>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub user: Option<Vec<String>>,
57}
58
59#[derive(Debug, Serialize, Deserialize)]
60pub struct Settings {
61    pub org_deploy_enabled: bool,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub socket_mode_enabled: Option<bool>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub token_rotation_enabled: Option<bool>,
66}
67
68/// Generate Slack App Manifest YAML from OAuth configuration
69///
70/// # Arguments
71/// * `_client_id` - OAuth client ID (not currently used in manifest generation)
72/// * `bot_scopes` - Bot OAuth scopes
73/// * `user_scopes` - User OAuth scopes
74/// * `redirect_uri` - OAuth redirect URI
75/// * `use_cloudflared` - Whether cloudflared tunnel is used (affects redirect_urls)
76/// * `use_ngrok` - Whether ngrok tunnel is used (affects redirect_urls)
77/// * `profile_name` - Profile name (used for bot display name)
78///
79/// # Returns
80/// YAML string representation of the Slack App Manifest
81pub fn generate_manifest(
82    _client_id: &str,
83    bot_scopes: &[String],
84    user_scopes: &[String],
85    redirect_uri: &str,
86    _use_cloudflared: bool,
87    _use_ngrok: bool,
88    profile_name: &str,
89) -> Result<String, String> {
90    // Determine redirect URLs based on whether cloudflared or ngrok is used
91    // Note: Slack does not accept wildcard URLs in manifests, so we only include the actual redirect_uri
92    let redirect_urls = vec![redirect_uri.to_string()];
93
94    let manifest = AppManifest {
95        _metadata: Metadata {
96            major_version: 2,
97            minor_version: 1,
98        },
99        display_information: DisplayInformation {
100            name: format!("slack-rs ({})", profile_name),
101            description: Some(format!(
102                "Slack CLI application for profile '{}'",
103                profile_name
104            )),
105            background_color: Some("#2c2d30".to_string()),
106        },
107        features: Features {
108            bot_user: BotUser {
109                display_name: format!("slack-rs-{}", profile_name),
110                always_online: false,
111            },
112        },
113        oauth_config: OAuthConfig {
114            redirect_urls,
115            scopes: Scopes {
116                bot: if bot_scopes.is_empty() {
117                    None
118                } else {
119                    Some(bot_scopes.to_vec())
120                },
121                user: if user_scopes.is_empty() {
122                    None
123                } else {
124                    Some(user_scopes.to_vec())
125                },
126            },
127        },
128        settings: Settings {
129            org_deploy_enabled: false,
130            socket_mode_enabled: Some(false),
131            token_rotation_enabled: Some(false),
132        },
133    };
134
135    // Serialize to YAML with explicit configuration
136    let yaml_string = serde_yaml::to_string(&manifest)
137        .map_err(|e| format!("Failed to serialize manifest: {}", e))?;
138
139    // Verify the YAML starts correctly
140    if !yaml_string.starts_with("_metadata:") && !yaml_string.starts_with("\"_metadata\":") {
141        return Err(format!(
142            "Generated YAML does not start with _metadata field. First line: {}",
143            yaml_string.lines().next().unwrap_or("(empty)")
144        ));
145    }
146
147    Ok(yaml_string)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_generate_manifest_with_bot_scopes_only() {
156        let bot_scopes = vec!["chat:write".to_string(), "users:read".to_string()];
157        let user_scopes = vec![];
158        let result = generate_manifest(
159            "test-client-id",
160            &bot_scopes,
161            &user_scopes,
162            "http://localhost:8765/callback",
163            false,
164            false,
165            "default",
166        );
167
168        assert!(result.is_ok());
169        let yaml = result.unwrap();
170
171        // Print YAML for debugging
172        println!("Generated YAML:\n{}", yaml);
173
174        // Verify YAML structure
175        assert!(yaml.contains("_metadata:"));
176        assert!(yaml.contains("major_version: 2"));
177        assert!(yaml.contains("minor_version: 1"));
178        assert!(yaml.contains("display_information:"));
179        assert!(yaml.contains("features:"));
180        assert!(yaml.contains("oauth_config:"));
181        assert!(yaml.contains("settings:"));
182
183        // Verify scopes
184        assert!(yaml.contains("chat:write"));
185        assert!(yaml.contains("users:read"));
186        assert!(yaml.contains("http://localhost:8765/callback"));
187        assert!(yaml.contains("slack-rs (default)"));
188        assert!(yaml.contains("bot:"));
189        assert!(yaml.contains("scopes:"));
190
191        // Verify YAML can be parsed back
192        let parsed: Result<AppManifest, _> = serde_yaml::from_str(&yaml);
193        assert!(
194            parsed.is_ok(),
195            "Generated YAML should be valid and parseable"
196        );
197    }
198
199    #[test]
200    fn test_generate_manifest_with_cloudflared() {
201        let bot_scopes = vec!["chat:write".to_string()];
202        let user_scopes = vec!["search:read".to_string()];
203        let result = generate_manifest(
204            "test-client-id",
205            &bot_scopes,
206            &user_scopes,
207            "http://localhost:8765/callback",
208            true,
209            false,
210            "work",
211        );
212
213        assert!(result.is_ok());
214        let yaml = result.unwrap();
215        // Wildcard URLs are not supported by Slack, so only the actual redirect_uri is included
216        assert!(yaml.contains("http://localhost:8765/callback"));
217        assert!(yaml.contains("chat:write"));
218        assert!(yaml.contains("search:read"));
219    }
220
221    #[test]
222    fn test_generate_manifest_with_user_scopes() {
223        let bot_scopes = vec!["chat:write".to_string()];
224        let user_scopes = vec!["users:read".to_string(), "search:read".to_string()];
225        let result = generate_manifest(
226            "test-client-id",
227            &bot_scopes,
228            &user_scopes,
229            "http://localhost:8765/callback",
230            false,
231            false,
232            "personal",
233        );
234
235        assert!(result.is_ok());
236        let yaml = result.unwrap();
237        assert!(yaml.contains("chat:write"));
238        assert!(yaml.contains("users:read"));
239        assert!(yaml.contains("search:read"));
240        assert!(yaml.contains("bot:"));
241        assert!(yaml.contains("user:"));
242    }
243
244    #[test]
245    fn test_generate_manifest_empty_scopes() {
246        let bot_scopes = vec![];
247        let user_scopes = vec![];
248        let result = generate_manifest(
249            "test-client-id",
250            &bot_scopes,
251            &user_scopes,
252            "http://localhost:8765/callback",
253            false,
254            false,
255            "empty",
256        );
257
258        // Should still generate a valid manifest even with empty scopes
259        assert!(result.is_ok());
260    }
261
262    #[test]
263    fn test_generate_manifest_with_ngrok() {
264        let bot_scopes = vec!["chat:write".to_string()];
265        let user_scopes = vec!["search:read".to_string()];
266        let result = generate_manifest(
267            "test-client-id",
268            &bot_scopes,
269            &user_scopes,
270            "http://localhost:8765/callback",
271            false,
272            true,
273            "ngrok-test",
274        );
275
276        assert!(result.is_ok());
277        let yaml = result.unwrap();
278        // Wildcard URLs are not supported by Slack, so only the actual redirect_uri is included
279        assert!(yaml.contains("http://localhost:8765/callback"));
280        assert!(yaml.contains("chat:write"));
281        assert!(yaml.contains("search:read"));
282    }
283}