Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
59 changes: 56 additions & 3 deletions crates/aionui-channel/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Option<PluginConfig>, 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<PluginConfig, ChannelError> {
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) {
Expand Down
20 changes: 20 additions & 0 deletions crates/aionui-channel/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,26 @@ pub struct PluginCredentials {
pub extra: HashMap<String, serde_json::Value>,
}

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,
Expand Down
28 changes: 28 additions & 0 deletions crates/aionui-channel/tests/manager_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down