use actix_web::http::{header, StatusCode};
use actix_web::{test, web, App};
use tempfile::tempdir;
use super::{configure_routes, configure_routes_with_rate_limiting};
use crate::AppState;
use bamboo_config::AccessControlConfig;
#[actix_web::test]
async fn configure_routes_registers_expected_api_prefixes() {
let app = test::init_service(App::new().configure(configure_routes)).await;
let requests = vec![
("GET", "/api/v1/health"),
("GET", "/healthz"),
("GET", "/readyz"),
("POST", "/api/v1/sessions/example/project-dream/run"),
("GET", "/api/v1/sessions/example/discoverable-tools"),
("POST", "/api/v1/sessions/example/discoverable-tools"),
("DELETE", "/api/v1/sessions/example/discoverable-tools"),
("GET", "/v1/bamboo/workflows"),
("GET", "/v1/bamboo/access/status"),
("GET", "/openai/v1/models"),
("GET", "/anthropic/v1/models"),
("GET", "/gemini/v1beta/models"),
];
for (method, uri) in requests {
let req = match method {
"POST" => test::TestRequest::post().uri(uri).to_request(),
"DELETE" => test::TestRequest::delete().uri(uri).to_request(),
_ => test::TestRequest::get().uri(uri).to_request(),
};
let resp = test::call_service(&app, req).await;
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"expected route to be registered: {method} {uri}"
);
}
}
#[actix_web::test]
async fn configure_routes_with_rate_limiting_registers_expected_api_prefixes() {
let app = test::init_service(App::new().configure(configure_routes_with_rate_limiting)).await;
let requests = vec![
("GET", "/api/v1/health"),
("GET", "/healthz"),
("GET", "/readyz"),
("POST", "/api/v1/sessions/example/project-dream/run"),
("GET", "/api/v1/sessions/example/discoverable-tools"),
("POST", "/api/v1/sessions/example/discoverable-tools"),
("DELETE", "/api/v1/sessions/example/discoverable-tools"),
("GET", "/v1/bamboo/workflows"),
("GET", "/v1/bamboo/access/status"),
("GET", "/openai/v1/models"),
("GET", "/anthropic/v1/models"),
("GET", "/gemini/v1beta/models"),
];
for (method, uri) in requests {
let req = match method {
"POST" => test::TestRequest::post().uri(uri).to_request(),
"DELETE" => test::TestRequest::delete().uri(uri).to_request(),
_ => test::TestRequest::get().uri(uri).to_request(),
};
let resp = test::call_service(&app, req).await;
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"expected route to be registered: {method} {uri}"
);
}
}
#[actix_web::test]
async fn remote_unverified_request_is_blocked_by_access_middleware() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(
"a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d".to_string(),
),
password_salt: Some("01010101010101010101010101010101".to_string()),
updated_at: None,
devices: Vec::new(),
});
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn access_bootstrap_endpoints_remain_public() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(
"a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d".to_string(),
),
password_salt: Some("01010101010101010101010101010101".to_string()),
updated_at: None,
devices: Vec::new(),
});
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
for req in [
test::TestRequest::get()
.uri("/v1/bamboo/access/status")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request(),
test::TestRequest::get()
.uri("/api/v1/health")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request(),
] {
let resp = test::call_service(&app, req).await;
assert_ne!(resp.status(), StatusCode::UNAUTHORIZED);
}
}
#[actix_web::test]
async fn verified_cookie_allows_remote_request_through_middleware() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(
"a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d".to_string(),
),
password_salt: Some("01010101010101010101010101010101".to_string()),
updated_at: None,
devices: Vec::new(),
});
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let verify_req = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "secret" }))
.to_request();
let verify_resp = test::call_service(&app, verify_req).await;
assert_eq!(verify_resp.status(), StatusCode::OK);
let set_cookie = verify_resp
.headers()
.get(header::SET_COOKIE)
.expect("verify response should set cookie")
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_string();
let protected_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::COOKIE, set_cookie))
.to_request();
let protected_resp = test::call_service(&app, protected_req).await;
assert_eq!(protected_resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn system_prompt_snapshot_route_returns_project_dream_over_http() {
let data_dir = tempdir().unwrap();
bamboo_config::paths::init_bamboo_dir(data_dir.path().to_path_buf());
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
let mut session = bamboo_agent_core::Session::new("session-http-project-dream", "gpt-5");
session.add_message(bamboo_agent_core::Message::system(
"Base prompt\n\n<!-- BAMBOO_EXTERNAL_MEMORY_START -->\n## External Memory (Persistent)\n\n### Project Dream Summary\n````md\nHTTP project dream content\n````\n\n### Session Memory Note (markdown)\n````md\nHTTP session note content\n````\n<!-- BAMBOO_EXTERNAL_MEMORY_END -->",
));
app_state
.storage
.save_session(&session)
.await
.expect("save session");
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::get()
.uri("/api/v1/sessions/session-http-project-dream/system-prompt")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = actix_web::body::to_bytes(resp.into_body())
.await
.expect("read response body");
let payload: serde_json::Value = serde_json::from_slice(&body).expect("parse response payload");
assert_eq!(
payload["dream_notebook"],
serde_json::json!("HTTP project dream content")
);
assert_eq!(
payload["project_dream"],
serde_json::json!("HTTP project dream content")
);
assert_eq!(
payload["session_memory_note"],
serde_json::json!("HTTP session note content")
);
assert!(payload.get("global_dream_fallback").is_none());
assert!(payload["external_memory"]
.as_str()
.unwrap_or_default()
.contains("### Project Dream Summary"));
}
#[actix_web::test]
async fn local_request_bypasses_access_middleware() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(
"a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d".to_string(),
),
password_salt: Some("01010101010101010101010101010101".to_string()),
updated_at: None,
devices: Vec::new(),
});
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn dev_reset_route_registered_when_dev_endpoints_enabled() {
let app = test::init_service(App::new().configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/api/v1/dev/reset")
.to_request();
let resp = test::call_service(&app, req).await;
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"dev/reset must be registered when dev endpoints are enabled"
);
}
#[actix_web::test]
async fn v2_stream_route_is_registered_and_reaches_handler() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::get().uri("/v2/stream").to_request();
let resp = test::call_service(&app, req).await;
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"/v2/stream must be registered"
);
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a non-WebSocket GET reaches the handler and is rejected by actix_ws::handle"
);
}
#[actix_web::test]
async fn v2_stream_upgrade_is_open_but_siblings_stay_gated() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(AccessControlConfig {
password_enabled: true,
password_hash: Some(
"a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d".to_string(),
),
password_salt: Some("01010101010101010101010101010101".to_string()),
updated_at: None,
devices: Vec::new(),
});
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::get()
.uri("/v2/stream")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"/v2/stream upgrade must NOT be middleware-rejected (#189: hello carries auth)"
);
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a non-WebSocket GET reaches the handler and is rejected by actix_ws::handle"
);
let gated = test::TestRequest::post()
.uri("/v2/pair/code")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request();
let resp = test::call_service(&app, gated).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"/v2/pair/code must stay middleware-gated"
);
let gated = test::TestRequest::get()
.uri("/v2/devices")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request();
let resp = test::call_service(&app, gated).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"/v2/devices must stay middleware-gated"
);
}
const SECRET_HASH: &str = "a65192f8d645bc4d19765b8ea61bfbb896dc999cb88a4be419518c5493f92c9d";
const SECRET_SALT: &str = "01010101010101010101010101010101";
fn password_access_control() -> AccessControlConfig {
AccessControlConfig {
password_enabled: true,
password_hash: Some(SECRET_HASH.to_string()),
password_salt: Some(SECRET_SALT.to_string()),
updated_at: None,
devices: Vec::new(),
}
}
#[actix_web::test]
async fn v2_pair_issues_token_that_authenticates_remote_request() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let pair_req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "secret", "label": "iPhone 15" }))
.to_request();
let pair_resp = test::call_service(&app, pair_req).await;
assert_eq!(pair_resp.status(), StatusCode::OK);
let body = actix_web::body::to_bytes(pair_resp.into_body())
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let device_id = payload["device_id"].as_str().unwrap().to_string();
let device_token = payload["device_token"].as_str().unwrap().to_string();
assert!(device_token.starts_with("bd1_"));
assert!(device_id.starts_with("bamboo_"));
{
let config = app_state.config.read().await;
let devices = &config.access_control.as_ref().unwrap().devices;
assert_eq!(devices.len(), 1);
assert_ne!(devices[0].token_hash, device_token);
}
let ok_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
.insert_header(("X-Device-Id", device_id))
.to_request();
let ok_resp = test::call_service(&app, ok_req).await;
assert_eq!(ok_resp.status(), StatusCode::OK);
let bad_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, "Bearer bd1_deadbeef"))
.insert_header(("X-Device-Id", "bamboo_000000000000"))
.to_request();
let bad_resp = test::call_service(&app, bad_req).await;
assert_eq!(bad_resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn v2_pair_rejects_wrong_root_password() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "wrong", "label": "x" }))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn v2_pair_requires_root_password_to_be_set() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "", "label": "x" }))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[actix_web::test]
async fn password_change_preserves_paired_devices() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let pair_req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "secret", "label": "iPad" }))
.to_request();
let pair_resp = test::call_service(&app, pair_req).await;
assert_eq!(pair_resp.status(), StatusCode::OK);
let device_count_before = app_state
.config
.read()
.await
.access_control
.as_ref()
.unwrap()
.devices
.len();
assert_eq!(device_count_before, 1);
let change_req = test::TestRequest::post()
.uri("/v1/bamboo/access/password")
.insert_header((header::HOST, "localhost:9562"))
.set_json(serde_json::json!({ "new_password": "newsecret" }))
.to_request();
let change_resp = test::call_service(&app, change_req).await;
assert_eq!(change_resp.status(), StatusCode::OK);
let config = app_state.config.read().await;
let access = config.access_control.as_ref().unwrap();
assert_eq!(
access.devices.len(),
1,
"password change must NOT wipe paired devices"
);
assert_eq!(access.devices[0].label, "iPad");
}
use crate::handlers::settings::PairingCodeEntry;
use std::time::Duration;
fn inject_code(app_state: &AppState, code: &str, ttl: Duration) {
app_state
.pairing_codes
.insert(code.to_string(), PairingCodeEntry::new(ttl));
}
fn inject_expired_code(app_state: &AppState, code: &str) {
app_state.pairing_codes.insert(
code.to_string(),
PairingCodeEntry::new(Duration::from_secs(0)),
);
}
#[actix_web::test]
async fn v2_pair_code_redeems_once_and_token_authenticates() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
inject_code(&app_state, "842913", Duration::from_secs(120));
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "842913", "label": "iPad" }))
.to_request();
let resp = test::call_service(&app, redeem).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let device_id = payload["device_id"].as_str().unwrap().to_string();
let device_token = payload["device_token"].as_str().unwrap().to_string();
assert!(device_token.starts_with("bd1_"));
let ok_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
.insert_header(("X-Device-Id", device_id))
.to_request();
assert_eq!(
test::call_service(&app, ok_req).await.status(),
StatusCode::OK
);
let again = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "842913", "label": "iPad2" }))
.to_request();
assert_eq!(
test::call_service(&app, again).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[actix_web::test]
async fn v2_pair_code_expired_is_rejected() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
inject_expired_code(&app_state, "111111");
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "111111", "label": "x" }))
.to_request();
assert_eq!(
test::call_service(&app, req).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[actix_web::test]
async fn v2_pair_code_unknown_is_rejected() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "999999", "label": "x" }))
.to_request();
assert_eq!(
test::call_service(&app, req).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[actix_web::test]
async fn v2_pair_code_brute_force_guard_trips_then_recovers() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
for _ in 0..10 {
let req = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "000000", "label": "x" }))
.to_request();
assert_eq!(
test::call_service(&app, req).await.status(),
StatusCode::UNAUTHORIZED
);
}
inject_code(&app_state, "123456", Duration::from_secs(120));
let blocked = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "123456", "label": "x" }))
.to_request();
assert_eq!(
test::call_service(&app, blocked).await.status(),
StatusCode::UNAUTHORIZED,
"valid code must be blocked during cooldown"
);
app_state.pairing_code_guard.record_success();
inject_code(&app_state, "654321", Duration::from_secs(120));
let ok = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "654321", "label": "after-cooldown" }))
.to_request();
assert_eq!(
test::call_service(&app, ok).await.status(),
StatusCode::OK,
"a correct code outside the cooldown must work"
);
}
#[actix_web::test]
async fn v2_devices_list_excludes_secret_material() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
inject_code(&app_state, "424242", Duration::from_secs(120));
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "424242", "label": "Pixel" }))
.to_request();
assert_eq!(
test::call_service(&app, redeem).await.status(),
StatusCode::OK
);
let (hash, salt) = {
let config = app_state.config.read().await;
let d = &config.access_control.as_ref().unwrap().devices[0];
(d.token_hash.clone(), d.token_salt.clone())
};
let list = test::TestRequest::get()
.uri("/v2/devices")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
let resp = test::call_service(&app, list).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
let raw = String::from_utf8(body.to_vec()).unwrap();
assert!(
!raw.contains("token_hash"),
"DTO must not expose token_hash key"
);
assert!(
!raw.contains("token_salt"),
"DTO must not expose token_salt key"
);
assert!(!raw.contains(&hash), "DTO must not leak the hash value");
assert!(!raw.contains(&salt), "DTO must not leak the salt value");
let arr: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(arr.as_array().unwrap().len(), 1);
assert_eq!(arr[0]["label"], "Pixel");
assert_eq!(arr[0]["revoked"], false);
}
#[actix_web::test]
async fn v2_devices_delete_revokes_and_404s_unknown() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
inject_code(&app_state, "333333", Duration::from_secs(120));
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "333333", "label": "Watch" }))
.to_request();
let resp = test::call_service(&app, redeem).await;
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let device_id = payload["device_id"].as_str().unwrap().to_string();
let device_token = payload["device_token"].as_str().unwrap().to_string();
let before = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
.insert_header(("X-Device-Id", device_id.clone()))
.to_request();
assert_eq!(
test::call_service(&app, before).await.status(),
StatusCode::OK
);
let del = test::TestRequest::delete()
.uri(&format!("/v2/devices/{device_id}"))
.insert_header((header::HOST, "localhost:9562"))
.to_request();
assert_eq!(test::call_service(&app, del).await.status(), StatusCode::OK);
{
let config = app_state.config.read().await;
let access = config.access_control.as_ref().unwrap();
assert_eq!(access.devices.len(), 1, "revoke keeps the audit row");
assert!(access.devices[0].revoked);
}
let after = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {device_token}")))
.insert_header(("X-Device-Id", device_id.clone()))
.to_request();
assert_eq!(
test::call_service(&app, after).await.status(),
StatusCode::UNAUTHORIZED,
"revoked token must stop authenticating immediately"
);
let unknown = test::TestRequest::delete()
.uri("/v2/devices/bamboo_doesnotexist")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
assert_eq!(
test::call_service(&app, unknown).await.status(),
StatusCode::NOT_FOUND
);
}
#[actix_web::test]
async fn v2_devices_rotate_swaps_token_and_404s_unknown() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
inject_code(&app_state, "555555", Duration::from_secs(120));
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "555555", "label": "Laptop" }))
.to_request();
let resp = test::call_service(&app, redeem).await;
let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let device_id = payload["device_id"].as_str().unwrap().to_string();
let old_token = payload["device_token"].as_str().unwrap().to_string();
let rot = test::TestRequest::post()
.uri(&format!("/v2/devices/{device_id}/rotate"))
.insert_header((header::HOST, "localhost:9562"))
.to_request();
let rot_resp = test::call_service(&app, rot).await;
assert_eq!(rot_resp.status(), StatusCode::OK);
let rbody = actix_web::body::to_bytes(rot_resp.into_body())
.await
.unwrap();
let rpayload: serde_json::Value = serde_json::from_slice(&rbody).unwrap();
let new_id = rpayload["device_id"].as_str().unwrap().to_string();
let new_token = rpayload["device_token"].as_str().unwrap().to_string();
assert_eq!(new_id, device_id, "device_id is unchanged across rotation");
assert_ne!(new_token, old_token, "rotation issues a different token");
let old_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {old_token}")))
.insert_header(("X-Device-Id", device_id.clone()))
.to_request();
assert_eq!(
test::call_service(&app, old_req).await.status(),
StatusCode::UNAUTHORIZED,
"old token must stop verifying after rotation"
);
let new_req = test::TestRequest::get()
.uri("/v1/bamboo/workflows")
.insert_header((header::HOST, "bamboo.example.com"))
.insert_header((header::AUTHORIZATION, format!("Bearer {new_token}")))
.insert_header(("X-Device-Id", device_id.clone()))
.to_request();
assert_eq!(
test::call_service(&app, new_req).await.status(),
StatusCode::OK
);
{
let config = app_state.config.read().await;
let d = &config.access_control.as_ref().unwrap().devices[0];
assert_eq!(d.label, "Laptop", "label preserved across rotation");
assert!(!d.revoked);
}
let unknown = test::TestRequest::post()
.uri("/v2/devices/bamboo_nope/rotate")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
assert_eq!(
test::call_service(&app, unknown).await.status(),
StatusCode::NOT_FOUND
);
}
#[actix_web::test]
async fn v2_pair_code_requires_auth() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let req = test::TestRequest::post()
.uri("/v2/pair/code")
.insert_header((header::HOST, "bamboo.example.com"))
.to_request();
assert_eq!(
test::call_service(&app, req).await.status(),
StatusCode::UNAUTHORIZED,
"/v2/pair/code must be behind the access middleware"
);
}
#[actix_web::test]
async fn v2_pair_code_local_issue_then_redeem() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(App::new().app_data(app_state).configure(configure_routes)).await;
let code_req = test::TestRequest::post()
.uri("/v2/pair/code")
.insert_header((header::HOST, "localhost:9562"))
.to_request();
let code_resp = test::call_service(&app, code_req).await;
assert_eq!(code_resp.status(), StatusCode::OK);
let body = actix_web::body::to_bytes(code_resp.into_body())
.await
.unwrap();
let payload: serde_json::Value = serde_json::from_slice(&body).unwrap();
let code = payload["code"].as_str().unwrap().to_string();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert_eq!(payload["ttl"].as_u64().unwrap(), 120);
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": code, "label": "redeemed" }))
.to_request();
assert_eq!(
test::call_service(&app, redeem).await.status(),
StatusCode::OK
);
}
#[actix_web::test]
async fn access_verify_throttles_after_threshold_per_ip() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let attacker = "203.0.113.50:5555".parse().unwrap();
let wrong = |peer| {
test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr(peer)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "wrong" }))
.to_request()
};
for _ in 0..5 {
let resp = test::call_service(&app, wrong(attacker)).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
let blocked = test::call_service(&app, wrong(attacker)).await;
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(
blocked.headers().get(header::RETRY_AFTER).is_some(),
"429 must carry a Retry-After header"
);
let other = "198.51.100.77:6666".parse().unwrap();
let ok = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr(other)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "secret" }))
.to_request();
assert_eq!(test::call_service(&app, ok).await.status(), StatusCode::OK);
}
#[actix_web::test]
async fn access_verify_success_resets_counter() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let peer = "203.0.113.51:5555".parse().unwrap();
for _ in 0..3 {
let resp = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr(peer)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "wrong" }))
.to_request();
assert_eq!(
test::call_service(&app, resp).await.status(),
StatusCode::UNAUTHORIZED
);
}
let ok = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr(peer)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "secret" }))
.to_request();
assert_eq!(test::call_service(&app, ok).await.status(), StatusCode::OK);
let after = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr(peer)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "password": "wrong" }))
.to_request();
assert_eq!(
test::call_service(&app, after).await.status(),
StatusCode::UNAUTHORIZED
);
}
#[actix_web::test]
async fn access_verify_loopback_is_never_throttled() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
for _ in 0..10 {
let resp = test::TestRequest::post()
.uri("/v1/bamboo/access/verify")
.peer_addr("127.0.0.1:12345".parse().unwrap())
.insert_header((header::HOST, "localhost:9562"))
.set_json(serde_json::json!({ "password": "wrong" }))
.to_request();
assert_eq!(
test::call_service(&app, resp).await.status(),
StatusCode::UNAUTHORIZED,
"loopback must never be throttled"
);
}
}
#[actix_web::test]
async fn v2_pair_root_password_throttles_after_threshold() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let attacker = "203.0.113.60:7777".parse().unwrap();
let wrong = || {
test::TestRequest::post()
.uri("/v2/pair")
.peer_addr(attacker)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "wrong", "label": "x" }))
.to_request()
};
for _ in 0..5 {
assert_eq!(
test::call_service(&app, wrong()).await.status(),
StatusCode::UNAUTHORIZED
);
}
let blocked = test::call_service(&app, wrong()).await;
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(blocked.headers().get(header::RETRY_AFTER).is_some());
}
#[actix_web::test]
async fn root_password_throttle_does_not_block_code_path() {
let data_dir = tempdir().unwrap();
let app_state = web::Data::new(AppState::new(data_dir.path().to_path_buf()).await.unwrap());
{
let mut config = app_state.config.write().await;
config.access_control = Some(password_access_control());
}
let app = test::init_service(
App::new()
.app_data(app_state.clone())
.configure(configure_routes),
)
.await;
let attacker = "203.0.113.61:8888".parse().unwrap();
for _ in 0..6 {
let _ = test::call_service(
&app,
test::TestRequest::post()
.uri("/v2/pair")
.peer_addr(attacker)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "root_password": "wrong", "label": "x" }))
.to_request(),
)
.await;
}
inject_code(&app_state, "424242", Duration::from_secs(120));
let redeem = test::TestRequest::post()
.uri("/v2/pair")
.peer_addr(attacker)
.insert_header((header::HOST, "bamboo.example.com"))
.set_json(serde_json::json!({ "code": "424242", "label": "code-device" }))
.to_request();
assert_eq!(
test::call_service(&app, redeem).await.status(),
StatusCode::OK,
"code path must be unaffected by the root-password throttle"
);
}