use std::fs;
use wecomx_transport::Transport;
use crate::bootstrap::{BindSource, fetch_auth};
use crate::bot::BotCredential;
use crate::credentials::{CredentialStore, Credentials};
use crate::crypto;
use crate::error::AuthError;
use crate::file_store::EncryptedFileCredentialStore;
#[allow(clippy::disallowed_methods)]
pub async fn try_migrate_legacy_credentials(
store: &EncryptedFileCredentialStore,
transport: &Transport,
auth_endpoint: &wecomx_transport::Endpoint,
) -> Result<bool, AuthError> {
if store.credentials_path().exists() {
return Ok(false);
}
let bot_path = store.legacy_paths()[0].clone();
if !bot_path.exists() {
return Ok(false);
}
let data = match fs::read(&bot_path) {
Ok(data) => data,
Err(e) => {
tracing::warn!(path = %bot_path.display(), error = %e, "read legacy bot.enc failed");
return Ok(false);
}
};
let bot: BotCredential = match crypto::try_decrypt_data(
store.dir(),
store.keyring_enabled(),
&data,
) {
Ok(bot) => bot,
Err(e) => {
tracing::warn!(path = %bot_path.display(), error = %e, "decrypt legacy bot.enc failed");
return Ok(false);
}
};
let resp = match fetch_auth(transport, &bot, BindSource::Interactive, auth_endpoint).await {
Ok(resp) => resp,
Err(e) => {
tracing::warn!(error = %e, "auto auth bootstrap failed during legacy migration");
return Ok(false);
}
};
let Some(token) = resp.token.filter(|t| !t.is_empty()) else {
tracing::warn!("legacy migration: auth response missing access token");
return Ok(false);
};
let creds = Credentials {
bot: Some(bot),
token: Some(token),
};
store.save(&creds)?;
tracing::info!("legacy credentials migrated to credentials.enc");
Ok(true)
}
#[cfg(test)]
mod tests {
use base64::prelude::*;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use wecomx_transport::HttpTransportBackend;
use super::*;
use crate::gateway::auth_endpoint;
fn write_key(dir: &std::path::Path, key: &[u8; 32]) {
#[allow(clippy::disallowed_methods)] std::fs::write(dir.join(".encryption_key"), BASE64_STANDARD.encode(key)).unwrap();
}
fn write_legacy_bot(dir: &std::path::Path, bot: &BotCredential, key: &[u8; 32]) {
let data = crypto::encrypt_data(bot, key).unwrap();
#[allow(clippy::disallowed_methods)] std::fs::write(dir.join("bot.enc"), data).unwrap();
}
fn test_transport(base_url: &str) -> Transport {
HttpTransportBackend::builder()
.base_url(base_url)
.build()
.expect("valid transport")
}
fn store(dir: &std::path::Path) -> EncryptedFileCredentialStore {
EncryptedFileCredentialStore::new(dir).with_keyring(false)
}
#[tokio::test]
async fn migrates_legacy_credentials() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
let key = crypto::generate_random_key();
write_key(dir.path(), &key);
let bot = BotCredential::new("bot-legacy".into(), "secret-legacy".into());
write_legacy_bot(dir.path(), &bot, &key);
#[allow(clippy::disallowed_methods)] std::fs::write(dir.path().join("token.enc"), b"legacy-token").unwrap();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/get_cli_config"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"errcode": 0,
"errmsg": "ok",
"token": "tok-migrated",
})))
.expect(1)
.mount(&server)
.await;
let transport = test_transport(&server.uri());
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint(&format!("{}/get_cli_config", server.uri())),
)
.await
.unwrap();
assert!(migrated, "expected migration to succeed");
assert!(dir.path().join("credentials.enc").exists());
assert!(dir.path().join("bot.enc").exists(), "legacy bot.enc kept");
assert!(
dir.path().join("token.enc").exists(),
"legacy token.enc kept"
);
let creds = store.load().unwrap().expect("credentials readable");
assert_eq!(creds.bot.as_ref().unwrap().id, "bot-legacy");
assert_eq!(creds.token.as_deref(), Some("tok-migrated"));
server.verify().await;
}
#[tokio::test]
async fn existing_credentials_skips_migration() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
write_key(dir.path(), &crypto::generate_random_key());
let creds = Credentials {
bot: Some(BotCredential::new("bot-new".into(), "secret-new".into())),
token: Some("tok-new".into()),
};
store.save(&creds).unwrap();
#[allow(clippy::disallowed_methods)]
std::fs::write(dir.path().join("bot.enc"), b"legacy").unwrap();
let transport = test_transport("http://localhost");
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint("http://localhost/get_cli_config"),
)
.await
.unwrap();
assert!(!migrated, "expected no migration");
assert!(dir.path().join("bot.enc").exists(), "legacy file untouched");
}
#[tokio::test]
async fn no_credentials_noop() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
let transport = test_transport("http://localhost");
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint("http://localhost/get_cli_config"),
)
.await
.unwrap();
assert!(!migrated);
assert!(!dir.path().join("credentials.enc").exists());
assert!(!dir.path().join("bot.enc").exists());
}
#[tokio::test]
async fn decrypt_failure_keeps_legacy() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
let key_a = crypto::generate_random_key();
let key_b = crypto::generate_random_key();
write_key(dir.path(), &key_b);
let bot = BotCredential::new("bot-legacy".into(), "secret-legacy".into());
write_legacy_bot(dir.path(), &bot, &key_a);
let transport = test_transport("http://localhost");
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint("http://localhost/get_cli_config"),
)
.await
.unwrap();
assert!(!migrated, "expected silent downgrade");
assert!(dir.path().join("bot.enc").exists(), "legacy file kept");
}
#[tokio::test]
async fn fetch_auth_failure_keeps_legacy() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
let key = crypto::generate_random_key();
write_key(dir.path(), &key);
let bot = BotCredential::new("bot-legacy".into(), "secret-legacy".into());
write_legacy_bot(dir.path(), &bot, &key);
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/get_cli_config"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"errcode": 853000,
"errmsg": "invalid credential",
})))
.expect(1)
.mount(&server)
.await;
let transport = test_transport(&server.uri());
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint(&format!("{}/get_cli_config", server.uri())),
)
.await
.unwrap();
assert!(!migrated, "expected silent downgrade");
assert!(dir.path().join("bot.enc").exists(), "legacy file kept");
server.verify().await;
}
#[tokio::test]
async fn token_only_keeps_legacy() {
let dir = tempfile::tempdir().unwrap();
let store = store(dir.path());
#[allow(clippy::disallowed_methods)]
std::fs::write(dir.path().join("token.enc"), b"legacy-token").unwrap();
let transport = test_transport("http://localhost");
let migrated = try_migrate_legacy_credentials(
&store,
&transport,
&auth_endpoint("http://localhost/get_cli_config"),
)
.await
.unwrap();
assert!(!migrated);
assert!(dir.path().join("token.enc").exists(), "token-only kept");
}
}