diff --git a/crates/aionui-channel/src/manager.rs b/crates/aionui-channel/src/manager.rs index 2d874e27..c673c76c 100644 --- a/crates/aionui-channel/src/manager.rs +++ b/crates/aionui-channel/src/manager.rs @@ -109,9 +109,14 @@ impl ChannelManager { let plugin_type = PluginType::from_str_opt(plugin_id).ok_or_else(|| ChannelError::InvalidPluginType(plugin_id.to_owned()))?; - // Parse and validate config structure - let config: PluginConfig = serde_json::from_value(config_value.clone()) - .map_err(|e| ChannelError::InvalidConfig(format!("Invalid config: {e}")))?; + // Resolve the effective config. The Settings re-enable toggle sends an + // empty config and expects the previously stored credentials to be + // reused, so fall back to the persisted config when no new credentials + // are supplied. + let config: PluginConfig = match Self::config_with_credentials(config_value)? { + Some(config) => config, + None => self.load_stored_config(plugin_id).await?, + }; // Stop existing plugin if running if self.plugins.contains_key(plugin_id) { @@ -368,6 +373,54 @@ impl ChannelManager { // ── Private helpers ────────────────────────────────────────────── + /// Parses a freshly supplied plugin config, returning it only when it + /// carries usable credentials. + /// + /// Returns `Ok(None)` when the caller supplied no credentials (an empty + /// `{}` config or one whose `credentials` field is absent or blank), + /// signalling that the stored configuration should be reused. A config that + /// does carry credentials but fails to parse is reported as `InvalidConfig`. + fn config_with_credentials(config_value: &serde_json::Value) -> Result, ChannelError> { + let credentials_supplied = config_value + .get("credentials") + .and_then(serde_json::Value::as_object) + .is_some_and(|creds| !creds.is_empty()); + if !credentials_supplied { + return Ok(None); + } + + let config: PluginConfig = serde_json::from_value(config_value.clone()) + .map_err(|e| ChannelError::InvalidConfig(format!("Invalid config: {e}")))?; + if config.credentials.is_empty() { + Ok(None) + } else { + Ok(Some(config)) + } + } + + /// Loads and decrypts the persisted config for a plugin. + /// + /// Used when an enable request omits credentials and the stored + /// configuration should be reused (Settings re-enable toggle). Returns + /// `InvalidConfig` when there is no stored config to fall back to. + async fn load_stored_config(&self, plugin_id: &str) -> Result { + let row = self + .repo + .get_plugin(plugin_id) + .await? + .filter(|row| !row.config.is_empty()) + .ok_or_else(|| { + ChannelError::InvalidConfig(format!( + "No credentials provided and no stored configuration for plugin '{plugin_id}'" + )) + })?; + + let config_json = decrypt_string(&row.config, &self.encryption_key) + .map_err(|e| ChannelError::DecryptionFailed(e.to_string()))?; + let config: PluginConfig = serde_json::from_str(&config_json)?; + Ok(config) + } + /// Stops and removes an active plugin instance. async fn stop_plugin(&self, plugin_id: &str) { if let Some((_, mut plugin)) = self.plugins.remove(plugin_id) { diff --git a/crates/aionui-channel/src/types.rs b/crates/aionui-channel/src/types.rs index 1f1664a1..8c3d3e22 100644 --- a/crates/aionui-channel/src/types.rs +++ b/crates/aionui-channel/src/types.rs @@ -194,6 +194,26 @@ pub struct PluginCredentials { pub extra: HashMap, } +impl PluginCredentials { + /// Returns true when no credential field carries a value. + /// + /// Used to detect "reuse stored credentials" enable requests: the Settings + /// re-enable toggle sends an empty config, so the manager must fall back to + /// the previously persisted credentials instead of failing. + pub fn is_empty(&self) -> bool { + self.token.is_none() + && self.app_id.is_none() + && self.app_secret.is_none() + && self.encrypt_key.is_none() + && self.verification_token.is_none() + && self.client_id.is_none() + && self.client_secret.is_none() + && self.account_id.is_none() + && self.bot_token.is_none() + && self.extra.is_empty() + } +} + /// Plugin connection options. /// /// Configures the connection mode, webhook URL, rate limiting, diff --git a/crates/aionui-channel/tests/manager_integration.rs b/crates/aionui-channel/tests/manager_integration.rs index efa675fb..5504949a 100644 --- a/crates/aionui-channel/tests/manager_integration.rs +++ b/crates/aionui-channel/tests/manager_integration.rs @@ -309,6 +309,34 @@ async fn ep2_re_enable_updates_config() { assert_eq!(config.credentials.token.as_deref(), Some("bot:new_token_456")); } +// ── EP-6: Re-enable with empty config reuses stored credentials ─── + +#[tokio::test] +async fn ep6_re_enable_empty_config_reuses_stored_credentials() { + let (mgr, repo, _bc) = setup().await; + let factory = make_factory(); + + // First enable persists the token, then the user disables the channel. + mgr.enable_plugin("telegram", &make_telegram_config(), &factory) + .await + .unwrap(); + mgr.disable_plugin("telegram").await.unwrap(); + + // The Settings re-enable toggle sends an empty config and relies on the + // previously stored credentials being reused instead of erroring out. + mgr.enable_plugin("telegram", &serde_json::json!({}), &factory) + .await + .unwrap(); + + assert!(mgr.is_plugin_running("telegram")); + + let row = repo.get_plugin("telegram").await.unwrap().unwrap(); + assert!(row.enabled); + let decrypted = decrypt_string(&row.config, &test_key()).unwrap(); + let config: PluginConfig = serde_json::from_str(&decrypted).unwrap(); + assert_eq!(config.credentials.token.as_deref(), Some("bot:valid123")); +} + // ── EP-5: Invalid plugin ID ────────────────────────────────────── #[tokio::test]