use std::collections::BTreeMap;
use tempfile::tempdir;
use bamboo_config::OpenAIConfig;
use bamboo_llm::Config;
use super::common::{
config_file_path, connect_backup_file_path, connect_file_path, model_limits_file_path,
read_model_limits_file, redacted_config_json, write_model_limits_file,
};
use super::reset::remove_config_file_if_exists;
#[test]
fn config_file_path_appends_config_json_filename() {
let dir = tempdir().expect("temp dir should be created");
assert_eq!(config_file_path(dir.path()), dir.path().join("config.json"));
}
#[test]
fn model_limits_file_path_appends_model_limits_json_filename() {
let dir = tempdir().expect("temp dir should be created");
assert_eq!(
model_limits_file_path(dir.path()),
dir.path().join("model_limits.json")
);
}
#[test]
fn connect_file_path_appends_connect_json_filename() {
let dir = tempdir().expect("temp dir should be created");
assert_eq!(
connect_file_path(dir.path()),
dir.path().join("connect.json")
);
}
#[test]
fn connect_backup_file_path_appends_connect_json_bak_filename() {
let dir = tempdir().expect("temp dir should be created");
assert_eq!(
connect_backup_file_path(dir.path()),
dir.path().join("connect.json.bak")
);
}
#[actix_web::test]
async fn redacted_config_json_masks_provider_api_key_and_hides_encrypted_proxy_auth() {
let mut config = Config::default();
config.providers.openai = Some(OpenAIConfig {
api_key: "sk-secret".to_string(),
api_key_from_env: false,
api_key_encrypted: None,
base_url: None,
model: None,
fast_model: None,
vision_model: None,
reasoning_effort: None,
responses_only_models: vec![],
request_overrides: None,
extra: BTreeMap::new(),
});
config.proxy_auth_encrypted = Some("enc:deadbeef".to_string());
let dir = tempdir().expect("temp dir should be created");
let value = redacted_config_json(&config, dir.path())
.await
.expect("redacted config should serialize");
assert_eq!(value["providers"]["openai"]["api_key"], "****...****");
assert!(value.get("proxy_auth_encrypted").is_none());
}
#[actix_web::test]
async fn remove_config_file_if_exists_deletes_existing_file() {
let dir = tempdir().expect("temp dir should be created");
let path = dir.path().join("config.json");
tokio::fs::write(&path, "{}")
.await
.expect("test file should be written");
remove_config_file_if_exists(&path)
.await
.expect("existing config file should be deleted");
assert!(!path.exists());
}
#[actix_web::test]
async fn remove_config_file_if_exists_is_noop_when_missing() {
let dir = tempdir().expect("temp dir should be created");
let path = dir.path().join("config.json");
remove_config_file_if_exists(&path)
.await
.expect("missing config file should not fail");
assert!(!path.exists());
}
#[actix_web::test]
async fn redacted_config_json_injects_model_limits_from_file() {
let dir = tempdir().expect("temp dir should be created");
write_model_limits_file(
dir.path(),
Some(&serde_json::json!([
{
"model_pattern": "gpt-5",
"max_context_tokens": 400000,
"max_output_tokens": 128000,
"safety_margin": 1000
}
])),
)
.await
.expect("model limits file should be written");
let value = redacted_config_json(&Config::default(), dir.path())
.await
.expect("redacted config should serialize");
assert_eq!(value["model_limits"][0]["model_pattern"], "gpt-5");
}
#[actix_web::test]
async fn remove_config_file_if_exists_deletes_model_limits_file() {
let dir = tempdir().expect("temp dir should be created");
let path = dir.path().join("model_limits.json");
tokio::fs::write(&path, "[]")
.await
.expect("model limits file should be written");
remove_config_file_if_exists(&path)
.await
.expect("existing model limits file should be deleted");
assert!(!path.exists());
}
#[actix_web::test]
async fn write_model_limits_file_persists_all_rows_round_trip() {
let dir = tempdir().expect("temp dir should be created");
write_model_limits_file(
dir.path(),
Some(&serde_json::json!([
{ "model_pattern": "gpt-4o", "max_context_tokens": 128000, "max_output_tokens": 16384 },
{ "model_pattern": "some-default-model", "max_context_tokens": 1000000, "max_output_tokens": 64000 }
])),
)
.await
.expect("model limits file should be written");
let persisted = read_model_limits_file(dir.path())
.await
.expect("read should succeed")
.expect("file should exist");
let rows = persisted.as_array().expect("array");
assert_eq!(rows.len(), 2, "all rows must be persisted");
assert_eq!(rows[0]["model_pattern"], "gpt-4o");
assert_eq!(rows[1]["model_pattern"], "some-default-model");
}
#[actix_web::test]
async fn write_model_limits_file_removes_file_when_empty() {
let dir = tempdir().expect("temp dir should be created");
let path = model_limits_file_path(dir.path());
tokio::fs::write(
&path,
r#"[{"model_pattern":"gpt-4o","max_context_tokens":128000,"max_output_tokens":16384}]"#,
)
.await
.expect("seed file");
write_model_limits_file(dir.path(), Some(&serde_json::json!([])))
.await
.expect("write should succeed");
assert!(
!path.exists(),
"file should be removed when overrides list is empty"
);
assert!(read_model_limits_file(dir.path())
.await
.expect("read should succeed")
.is_none());
}
#[actix_web::test]
async fn write_model_limits_file_keeps_custom_safety_margin() {
let dir = tempdir().expect("temp dir should be created");
write_model_limits_file(
dir.path(),
Some(&serde_json::json!([
{ "model_pattern": "gpt-4o", "max_context_tokens": 200000, "max_output_tokens": 64000, "safety_margin": 500 }
])),
)
.await
.expect("write should succeed");
let persisted = read_model_limits_file(dir.path())
.await
.expect("read should succeed")
.expect("file should exist");
let rows = persisted.as_array().expect("array");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["safety_margin"], 500);
}
#[actix_web::test]
async fn set_then_get_bamboo_config_round_trips_all_overrides() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config)),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"model": "gpt-4o",
"model_limits": [
{ "model_pattern": "gpt-4o", "max_context_tokens": 128000, "max_output_tokens": 16384 },
{ "model_pattern": "noop", "max_context_tokens": 200000, "max_output_tokens": 64000 }
]
}))
.to_request();
let post_resp = test::call_service(&app, post).await;
assert!(post_resp.status().is_success(), "set config should succeed");
let on_disk = read_model_limits_file(&data_dir)
.await
.expect("read should succeed")
.expect("model_limits.json should exist");
let rows = on_disk.as_array().expect("array");
assert_eq!(rows.len(), 2, "all rows must be persisted");
assert_eq!(rows[0]["model_pattern"], "gpt-4o");
assert_eq!(rows[1]["model_pattern"], "noop");
let config_text = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist after set");
let config_json: serde_json::Value =
serde_json::from_str(&config_text).expect("config.json should be valid json");
assert!(
config_json.get("model_limits").is_none(),
"model_limits must be split out of config.json"
);
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
let limits = body["model_limits"]
.as_array()
.expect("model_limits should be present in GET response");
assert_eq!(limits.len(), 2);
assert_eq!(limits[0]["model_pattern"], "gpt-4o");
assert_eq!(limits[0]["max_context_tokens"], 128000);
}
#[actix_web::test]
async fn set_then_get_bamboo_config_masks_and_preserves_ntfy_token() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config)),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"notifications": {
"ntfy": {
"enabled": true,
"base_url": "https://ntfy.sh",
"topic": "bamboo-alerts",
"token": "tk-real-secret"
}
}
}))
.to_request();
let post_resp = test::call_service(&app, post).await;
assert!(post_resp.status().is_success(), "set config should succeed");
let config_text = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist after set");
assert!(
config_text.contains("token_encrypted"),
"ntfy token must be persisted encrypted"
);
assert!(
!config_text.contains("tk-real-secret"),
"plaintext ntfy token must never be persisted"
);
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert_eq!(body["notifications"]["ntfy"]["token"], "****...****");
assert_eq!(body["notifications"]["ntfy"]["topic"], "bamboo-alerts");
let post2 = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"notifications": {
"ntfy": {
"enabled": true,
"base_url": "https://ntfy.sh",
"topic": "renamed-topic",
"token": "****...****"
}
}
}))
.to_request();
let post2_resp = test::call_service(&app, post2).await;
assert!(
post2_resp.status().is_success(),
"second set config should succeed"
);
let get2 = test::TestRequest::get().uri("/bamboo/config").to_request();
let body2: serde_json::Value = test::call_and_read_body_json(&app, get2).await;
assert_eq!(
body2["notifications"]["ntfy"]["token"], "****...****",
"token must still read as configured"
);
assert_eq!(
body2["notifications"]["ntfy"]["topic"], "renamed-topic",
"unrelated field change must apply"
);
}
#[actix_web::test]
async fn set_bamboo_config_persists_connect_into_connect_json_only_and_preserves_masked_token() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
use bamboo_config::encryption;
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config)),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "tg-real-secret", "allow_from": ["u1"] }
]
}
}))
.to_request();
let post_resp = test::call_service(&app, post).await;
assert!(post_resp.status().is_success(), "set config should succeed");
let config_text = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist after set");
assert!(
!config_text.contains("\"connect\""),
"config.json must not persist the connect key"
);
assert!(
!config_text.contains("tg-real-secret"),
"plaintext connect token must never be persisted anywhere"
);
let connect_path = data_dir.join("connect.json");
let connect_text = tokio::fs::read_to_string(&connect_path)
.await
.expect("connect.json should exist after set");
assert!(
connect_text.contains("token_encrypted"),
"connect token must be persisted encrypted in connect.json"
);
assert!(
!connect_text.contains("tg-real-secret"),
"plaintext connect token must never be persisted in connect.json"
);
let connect_json: serde_json::Value =
serde_json::from_str(&connect_text).expect("connect.json should parse");
let token_encrypted = connect_json["platforms"][0]["token_encrypted"]
.as_str()
.expect("token_encrypted should be present")
.to_string();
assert_eq!(
encryption::decrypt(&token_encrypted).expect("token should decrypt"),
"tg-real-secret"
);
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert_eq!(body["connect"]["platforms"][0]["token"], "****...****");
assert_eq!(body["connect"]["platforms"][0]["type"], "telegram");
let post2 = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "****...****", "allow_from": ["u1", "u2"] }
]
}
}))
.to_request();
let post2_resp = test::call_service(&app, post2).await;
assert!(
post2_resp.status().is_success(),
"second set config should succeed"
);
let get2 = test::TestRequest::get().uri("/bamboo/config").to_request();
let body2: serde_json::Value = test::call_and_read_body_json(&app, get2).await;
assert_eq!(
body2["connect"]["platforms"][0]["token"], "****...****",
"token must still read as configured"
);
assert_eq!(
body2["connect"]["platforms"][0]["allow_from"],
serde_json::json!(["u1", "u2"]),
"unrelated field change must apply"
);
let connect_text_after = tokio::fs::read_to_string(&connect_path)
.await
.expect("connect.json should still exist");
let connect_json_after: serde_json::Value =
serde_json::from_str(&connect_text_after).expect("connect.json should parse");
let token_encrypted_after = connect_json_after["platforms"][0]["token_encrypted"]
.as_str()
.expect("token_encrypted should still be present")
.to_string();
assert_eq!(
encryption::decrypt(&token_encrypted_after).expect("token should decrypt"),
"tg-real-secret",
"masked round-trip preserves the real token across the split files"
);
let config_text_after = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should still exist");
assert!(
!config_text_after.contains("\"connect\""),
"config.json must still not carry the connect key after the second PATCH"
);
}
#[actix_web::test]
async fn set_bamboo_config_persists_feishu_app_secret_encrypted_and_preserves_masked_value() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
use bamboo_config::encryption;
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config)),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{
"type": "feishu",
"app_id": "cli_real_app_id",
"app_secret": "feishu-real-secret",
"domain": "lark",
"allow_from": ["ou_1"]
}
]
}
}))
.to_request();
let post_resp = test::call_service(&app, post).await;
assert!(post_resp.status().is_success(), "set config should succeed");
let config_text = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist after set");
assert!(
!config_text.contains("\"connect\""),
"config.json must not persist the connect key"
);
assert!(
!config_text.contains("feishu-real-secret"),
"plaintext app_secret must never be persisted anywhere"
);
let connect_path = data_dir.join("connect.json");
let connect_text = tokio::fs::read_to_string(&connect_path)
.await
.expect("connect.json should exist after set");
assert!(
connect_text.contains("app_secret_encrypted"),
"app_secret must be persisted encrypted in connect.json"
);
assert!(
!connect_text.contains("feishu-real-secret"),
"plaintext app_secret must never be persisted in connect.json"
);
let connect_json: serde_json::Value =
serde_json::from_str(&connect_text).expect("connect.json should parse");
assert_eq!(connect_json["platforms"][0]["app_id"], "cli_real_app_id");
assert_eq!(connect_json["platforms"][0]["domain"], "lark");
let app_secret_encrypted = connect_json["platforms"][0]["app_secret_encrypted"]
.as_str()
.expect("app_secret_encrypted should be present")
.to_string();
assert_eq!(
encryption::decrypt(&app_secret_encrypted).expect("app_secret should decrypt"),
"feishu-real-secret"
);
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert_eq!(body["connect"]["platforms"][0]["app_secret"], "****...****");
assert_eq!(body["connect"]["platforms"][0]["app_id"], "cli_real_app_id");
assert_eq!(body["connect"]["platforms"][0]["domain"], "lark");
let post2 = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{
"type": "feishu",
"app_id": "cli_real_app_id",
"app_secret": "****...****",
"domain": "lark",
"allow_from": ["ou_1", "ou_2"]
}
]
}
}))
.to_request();
let post2_resp = test::call_service(&app, post2).await;
assert!(
post2_resp.status().is_success(),
"second set config should succeed"
);
let get2 = test::TestRequest::get().uri("/bamboo/config").to_request();
let body2: serde_json::Value = test::call_and_read_body_json(&app, get2).await;
assert_eq!(
body2["connect"]["platforms"][0]["app_secret"], "****...****",
"app_secret must still read as configured"
);
assert_eq!(
body2["connect"]["platforms"][0]["allow_from"],
serde_json::json!(["ou_1", "ou_2"]),
"unrelated field change must apply"
);
let connect_text_after = tokio::fs::read_to_string(&connect_path)
.await
.expect("connect.json should still exist");
let connect_json_after: serde_json::Value =
serde_json::from_str(&connect_text_after).expect("connect.json should parse");
let app_secret_encrypted_after = connect_json_after["platforms"][0]["app_secret_encrypted"]
.as_str()
.expect("app_secret_encrypted should still be present")
.to_string();
assert_eq!(
encryption::decrypt(&app_secret_encrypted_after).expect("app_secret should decrypt"),
"feishu-real-secret",
"masked round-trip preserves the real app_secret across the split files"
);
}
#[actix_web::test]
async fn set_bamboo_config_preserves_masked_secret_when_preceding_platform_removed() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
use bamboo_config::encryption;
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config)),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "tg-real-secret", "allow_from": ["u1"] },
{
"type": "feishu",
"app_id": "cli_real_app_id",
"app_secret": "feishu-real-secret",
"domain": "lark",
"allow_from": ["ou_1"]
}
]
}
}))
.to_request();
let post_resp = test::call_service(&app, post).await;
assert!(post_resp.status().is_success(), "set config should succeed");
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert_eq!(body["connect"]["platforms"][0]["type"], "telegram");
assert_eq!(body["connect"]["platforms"][1]["type"], "feishu");
assert_eq!(body["connect"]["platforms"][1]["app_secret"], "****...****");
let post2 = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{
"type": "feishu",
"app_id": "cli_real_app_id",
"app_secret": "****...****",
"domain": "lark",
"allow_from": ["ou_1"]
}
]
}
}))
.to_request();
let post2_resp = test::call_service(&app, post2).await;
assert!(
post2_resp.status().is_success(),
"second set config should succeed"
);
let get2 = test::TestRequest::get().uri("/bamboo/config").to_request();
let body2: serde_json::Value = test::call_and_read_body_json(&app, get2).await;
assert_eq!(
body2["connect"]["platforms"][0]["type"], "feishu",
"telegram entry should be gone, feishu now at index 0"
);
assert_eq!(
body2["connect"]["platforms"][0]["app_secret"], "****...****",
"app_secret must still read as configured after the index shift"
);
let connect_path = data_dir.join("connect.json");
let connect_text_after = tokio::fs::read_to_string(&connect_path)
.await
.expect("connect.json should still exist");
let connect_json_after: serde_json::Value =
serde_json::from_str(&connect_text_after).expect("connect.json should parse");
assert_eq!(connect_json_after["platforms"].as_array().unwrap().len(), 1);
let app_secret_encrypted_after = connect_json_after["platforms"][0]["app_secret_encrypted"]
.as_str()
.expect("app_secret_encrypted should still be present")
.to_string();
assert_eq!(
encryption::decrypt(&app_secret_encrypted_after).expect("app_secret should decrypt"),
"feishu-real-secret",
"masked secret must resolve by type after a preceding entry was removed, not be dropped"
);
}
#[actix_web::test]
async fn reset_bamboo_config_also_deletes_connect_json() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config))
.route(
"/bamboo/config/reset",
web::post().to(super::reset_bamboo_config),
),
)
.await;
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": "tg-reset-me", "allow_from": ["u1"] }
]
}
}))
.to_request();
assert!(test::call_service(&app, post).await.status().is_success());
let connect_path = data_dir.join("connect.json");
assert!(
connect_path.exists(),
"connect.json should exist before reset"
);
let reset = test::TestRequest::post()
.uri("/bamboo/config/reset")
.to_request();
assert!(
test::call_service(&app, reset).await.status().is_success(),
"reset should succeed"
);
assert!(
!connect_path.exists(),
"connect.json must be deleted by a full config reset"
);
let get = test::TestRequest::get().uri("/bamboo/config").to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert!(
body["connect"]["platforms"]
.as_array()
.map(|a| a.is_empty())
.unwrap_or(true),
"connect config must be empty after reset, not re-adopted from a stale file"
);
}
#[actix_web::test]
async fn reset_bamboo_config_also_deletes_connect_json_bak() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app_state = web::Data::new(state);
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.route("/bamboo/config", web::get().to(super::get_bamboo_config))
.route("/bamboo/config", web::post().to(super::set_bamboo_config))
.route(
"/bamboo/config/reset",
web::post().to(super::reset_bamboo_config),
),
)
.await;
for token in ["tg-first-token", "tg-second-token"] {
let post = test::TestRequest::post()
.uri("/bamboo/config")
.set_json(serde_json::json!({
"connect": {
"platforms": [
{ "type": "telegram", "token": token, "allow_from": ["u1"] }
]
}
}))
.to_request();
assert!(test::call_service(&app, post).await.status().is_success());
}
let connect_path = data_dir.join("connect.json");
let connect_backup_path = data_dir.join("connect.json.bak");
assert!(
connect_path.exists(),
"connect.json should exist before reset"
);
assert!(
connect_backup_path.exists(),
"connect.json.bak should exist before reset (written by the second save)"
);
let reset = test::TestRequest::post()
.uri("/bamboo/config/reset")
.to_request();
assert!(
test::call_service(&app, reset).await.status().is_success(),
"reset should succeed"
);
assert!(
!connect_path.exists(),
"connect.json must be deleted by a full config reset"
);
assert!(
!connect_backup_path.exists(),
"connect.json.bak must also be deleted by a full config reset — it holds \
a live, immediately-usable bot token"
);
}
#[actix_web::test]
async fn get_config_recovery_status_reports_pending_state_after_corrupt_boot() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let clean_dir = tempdir().expect("temp dir should be created");
let clean_state = AppState::new(clean_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let clean_app = test::init_service(App::new().app_data(web::Data::new(clean_state)).route(
"/bamboo/config/recovery-status",
web::get().to(super::get_config_recovery_status),
))
.await;
let get = test::TestRequest::get()
.uri("/bamboo/config/recovery-status")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&clean_app, get).await;
assert_eq!(body["pending"], false);
let corrupt_dir = tempdir().expect("temp dir should be created");
std::fs::write(corrupt_dir.path().join("config.json"), "}}} broken").unwrap();
let corrupt_state = AppState::new(corrupt_dir.path().to_path_buf())
.await
.expect("app state should still initialize");
let corrupt_app = test::init_service(App::new().app_data(web::Data::new(corrupt_state)).route(
"/bamboo/config/recovery-status",
web::get().to(super::get_config_recovery_status),
))
.await;
let get = test::TestRequest::get()
.uri("/bamboo/config/recovery-status")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&corrupt_app, get).await;
assert_eq!(body["pending"], true);
assert_eq!(body["status"]["confirmed"], false);
assert_eq!(body["status"]["source"]["kind"], "defaults");
assert!(
body["status"]["quarantine_path"].is_string(),
"quarantine_path should point at the preserved corrupt original"
);
}
#[actix_web::test]
async fn confirm_config_recovery_endpoint_accept_clears_pending_state() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
std::fs::write(
temp_dir.path().join("config.json"),
r#"{"http_proxy":"http://salvaged","env_vars":"bad-type"}"#,
)
.unwrap();
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app = test::init_service(
App::new()
.app_data(web::Data::new(state))
.route(
"/bamboo/config/recovery-status",
web::get().to(super::get_config_recovery_status),
)
.route(
"/bamboo/config/recovery/confirm",
web::post().to(super::confirm_config_recovery),
),
)
.await;
let confirm = test::TestRequest::post()
.uri("/bamboo/config/recovery/confirm")
.set_json(serde_json::json!({ "accept": true }))
.to_request();
let confirm_resp = test::call_service(&app, confirm).await;
assert!(
confirm_resp.status().is_success(),
"accepting a pending recovery should succeed"
);
let body: serde_json::Value = test::read_body_json(confirm_resp).await;
assert_eq!(body["pending"], false);
let get = test::TestRequest::get()
.uri("/bamboo/config/recovery-status")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, get).await;
assert_eq!(
body["pending"], false,
"a follow-up GET agrees the recovery is resolved"
);
let on_disk = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist");
assert!(
on_disk.contains("http://salvaged"),
"the recovered state was actually persisted to config.json"
);
}
#[actix_web::test]
async fn confirm_config_recovery_endpoint_reject_leaves_pending_state() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let corrupt_bytes = "}}} broken";
std::fs::write(temp_dir.path().join("config.json"), corrupt_bytes).unwrap();
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let data_dir = state.app_data_dir.clone();
let app = test::init_service(App::new().app_data(web::Data::new(state)).route(
"/bamboo/config/recovery/confirm",
web::post().to(super::confirm_config_recovery),
))
.await;
let confirm = test::TestRequest::post()
.uri("/bamboo/config/recovery/confirm")
.set_json(serde_json::json!({ "accept": false }))
.to_request();
let confirm_resp = test::call_service(&app, confirm).await;
assert!(confirm_resp.status().is_success());
let body: serde_json::Value = test::read_body_json(confirm_resp).await;
assert_eq!(body["pending"], true, "reject leaves the recovery pending");
let on_disk = tokio::fs::read_to_string(config_file_path(&data_dir))
.await
.expect("config.json should exist");
assert_eq!(
on_disk, corrupt_bytes,
"reject must not touch the on-disk corrupt original"
);
}
#[actix_web::test]
async fn confirm_config_recovery_endpoint_errors_when_nothing_pending() {
use crate::app_state::AppState;
use actix_web::{test, web, App};
let temp_dir = tempdir().expect("temp dir should be created");
let state = AppState::new(temp_dir.path().to_path_buf())
.await
.expect("app state should initialize");
let app = test::init_service(App::new().app_data(web::Data::new(state)).route(
"/bamboo/config/recovery/confirm",
web::post().to(super::confirm_config_recovery),
))
.await;
let confirm = test::TestRequest::post()
.uri("/bamboo/config/recovery/confirm")
.set_json(serde_json::json!({ "accept": true }))
.to_request();
let confirm_resp = test::call_service(&app, confirm).await;
assert_eq!(
confirm_resp.status(),
actix_web::http::StatusCode::BAD_REQUEST
);
}