From 5ed1bc14bee170b26de009a1dfe7d7352d30acb8 Mon Sep 17 00:00:00 2001 From: szafranski Date: Thu, 11 Jun 2026 19:48:05 +0200 Subject: [PATCH] fix(channel): reuse stored credentials when re-enabling a plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings re-enable toggle sends an empty config `{}` and relies on the previously stored credentials. `enable_plugin` rejected this with `InvalidConfig("missing field credentials")`, so a channel could be enabled once but never re-enabled after being disabled — the toggle reverted and the bot never restarted. Resolve the effective config before starting: when an enable request omits credentials, fall back to the persisted (encrypted) config instead of failing. Configs that do carry credentials are still validated as before, so genuinely malformed input is reported as `InvalidConfig`. Co-Authored-By: Claude Opus 4.8 --- crates/aionui-channel/src/manager.rs | 59 ++++++++++++++++++- crates/aionui-channel/src/types.rs | 20 +++++++ .../tests/manager_integration.rs | 28 +++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) 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]