#![allow(clippy::panic)]
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
use openbao::{Client, Error, OpenBaoConfig, sys::DevBootstrapOptions};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct SecretData {
value: String,
}
#[derive(Serialize)]
struct WrappedData {
value: String,
}
#[tokio::test]
async fn kv2_read_sends_documented_headers_and_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("GET /v1/secret/data/app/config HTTP/1.1"));
assert!(request.contains("x-vault-request: true"));
assert!(request.contains("x-vault-token: test-token"));
let body = r#"{"data":{"data":{"value":"ok"},"metadata":{"created_time":"2026-05-27T00:00:00Z","deletion_time":"","destroyed":false,"version":1}}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let secret = client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read::<SecretData>("app/config")
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(secret.data.value, "ok");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn kv2_delete_accepts_no_content() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("DELETE /v1/secret/data/app/config HTTP/1.1"));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.delete_latest("app/config")
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn kv2_read_version_sends_version_query() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("GET /v1/secret/data/app/config?version=3 HTTP/1.1"));
let body = r#"{"data":{"data":{"value":"old"},"metadata":{"created_time":"2026-05-27T00:00:00Z","deletion_time":"","destroyed":false,"version":3}}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let secret = client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read_version::<SecretData>("app/config", 3)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(secret.data.value, "old");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn kv2_patch_sends_merge_patch_content_type() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("PATCH /v1/secret/data/app/config HTTP/1.1"));
assert!(request.contains("content-type: application/merge-patch+json"));
assert!(!request.contains("content-type: application/json"));
let body = r#"{"data":{"created_time":"2026-05-27T00:00:00Z","version":2}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let response = client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.patch("app/config", WrappedData { value: "ok".into() })
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(response.version, 2);
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn token_lookup_self_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/auth/token/lookup-self HTTP/1.1"));
assert!(request.contains("x-vault-token: test-token"));
let body = r#"{"data":{"accessor":"accessor-value","display_name":"token","policies":["default"],"renewable":true,"ttl":3600}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let info = client
.token()
.lookup_self()
.await
.unwrap_or_else(|error| panic!("{error}"));
assert!(info.renewable);
assert_eq!(info.policies, ["default"]);
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_enable_mount_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/mounts/secret HTTP/1.1"));
assert!(request.contains(r#""type":"kv""#));
assert!(request.contains(r#""version":"2""#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let mut options = std::collections::BTreeMap::new();
options.insert("version".to_owned(), "2".to_owned());
let request = openbao::sys::MountEnableRequest {
backend_type: "kv".to_owned(),
description: None,
config: None,
options,
local: None,
seal_wrap: None,
external_entropy_access: None,
};
client
.sys()
.enable_mount("secret", &request)
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_wrapping_wrap_sends_ttl_header() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/wrapping/wrap HTTP/1.1"));
assert!(request.contains("x-vault-wrap-ttl: 60s"));
assert!(request.contains(r#""value":"ok""#));
let body = r#"{"wrap_info":{"token":"wrapping-token","accessor":"wrapping-accessor","ttl":60,"creation_path":"sys/wrapping/wrap"}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let info = client
.sys()
.wrapping_wrap(
"60s",
&WrappedData {
value: "ok".to_owned(),
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
let debug = format!("{info:?}");
assert!(debug.contains("<redacted>"));
assert!(!debug.contains("wrapping-token"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_policy_write_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/policy/app-read HTTP/1.1"));
assert!(request.contains(r#""policy":"path \"secret/*\" { capabilities = [\"read\"] }""#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.sys()
.write_policy(
"app-read",
&openbao::sys::PolicyWriteRequest {
policy: r#"path "secret/*" { capabilities = ["read"] }"#.to_owned(),
expiration: None,
ttl: None,
cas: None,
cas_required: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_capabilities_self_sends_paths() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/capabilities-self HTTP/1.1"));
assert!(request.contains(r#""paths":["secret/data/app"]"#));
assert!(!request.contains(r#""token":"#));
let body = r#"{"data":{"capabilities":["read"],"secret/data/app":["read"]}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let capabilities = client
.sys()
.capabilities_self(["secret/data/app"])
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(capabilities.capabilities, ["read"]);
assert_eq!(
capabilities.by_path.get("secret/data/app"),
Some(&vec!["read".to_owned()])
);
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_enable_audit_device_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/audit/file HTTP/1.1"));
assert!(request.contains(r#""type":"file""#));
assert!(request.contains(r#""file_path":"/tmp/openbao-audit.log""#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let mut options = std::collections::BTreeMap::new();
options.insert("file_path".to_owned(), "/tmp/openbao-audit.log".to_owned());
let request = openbao::sys::AuditEnableRequest {
backend_type: "file".to_owned(),
description: None,
options,
local: None,
};
client
.sys()
.enable_audit_device("file", &request)
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_audit_hash_sends_secret_input() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/audit-hash/file HTTP/1.1"));
assert!(request.contains(r#""input":"secret-value""#));
let body = r#"{"hash":"hmac-sha256:abc123"}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let response = client
.sys()
.audit_hash("file", &SecretString::from("secret-value"))
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(response.hash, "hmac-sha256:abc123");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_lease_lookup_sends_json_body_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/leases/lookup HTTP/1.1"));
assert!(request.contains(r#""lease_id":"database/creds/readonly/abc""#));
let body = r#"{"data":{"expire_time":"2026-05-28T12:00:00Z","id":"database/creds/readonly/abc","issue_time":"2026-05-28T11:00:00Z","last_renewal":null,"renewable":true,"ttl":3600}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let lookup = client
.sys()
.lookup_lease(&SecretString::from("database/creds/readonly/abc"))
.await
.unwrap_or_else(|error| panic!("{error}"));
assert!(lookup.renewable);
assert_eq!(lookup.ttl, 3600);
let debug = format!("{lookup:?}");
assert!(debug.contains("<redacted>"));
assert!(!debug.contains("database/creds/readonly/abc"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_lease_renew_maps_response_envelope() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/leases/renew HTTP/1.1"));
assert!(request.contains(r#""lease_id":"database/creds/readonly/abc""#));
assert!(request.contains(r#""increment":1800"#));
let body =
r#"{"lease_id":"database/creds/readonly/abc","renewable":true,"lease_duration":1800}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let renewal = client
.sys()
.renew_lease(
&SecretString::from("database/creds/readonly/abc"),
Some(1800),
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert!(renewal.renewable);
assert_eq!(renewal.lease_duration, 1800);
let debug = format!("{renewal:?}");
assert!(debug.contains("<redacted>"));
assert!(!debug.contains("database/creds/readonly/abc"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_lease_revoke_uses_non_prefix_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/leases/revoke HTTP/1.1"));
assert!(request.contains(r#""lease_id":"database/creds/readonly/abc""#));
assert!(!request.contains("/revoke-prefix/"));
assert!(!request.contains("/revoke-force/"));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.sys()
.revoke_lease(&SecretString::from("database/creds/readonly/abc"))
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_plugin_catalog_lists_all_plugins() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("GET /v1/sys/plugins/catalog HTTP/1.1"));
let body = r#"{"data":{"auth":["ldap"],"database":["postgresql-database-plugin"],"secret":["transit"],"detailed":[{"builtin":true,"deprecation_status":"supported","name":"transit","type":"secret","version":"v2.5.4+builtin.openbao"}]}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let catalog = client
.sys()
.list_plugins()
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(catalog.secret, ["transit"]);
assert_eq!(catalog.detailed[0].name, "transit");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_plugin_catalog_entry_lifecycle_uses_documented_paths() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
for index in 0..3 {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
if index == 0 {
assert!(
request
.starts_with("POST /v1/sys/plugins/catalog/secret/example-plugin HTTP/1.1")
);
assert!(request.contains(r#""version":"v1.0.0""#));
assert!(request.contains(
r#""sha256":"d130b9a0fbfddef9709d8ff92e5e6053ccd246b78632fc03b8548457026961e9""#
));
assert!(request.contains(r#""command":"example-plugin""#));
assert!(request.contains(r#""args":["--config=/secure/path"]"#));
assert!(request.contains(r#""env":["TOKEN=secret"]"#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
} else if index == 1 {
assert!(request.starts_with(
"GET /v1/sys/plugins/catalog/secret/example-plugin?version=v1.0.0 HTTP/1.1"
));
let body = r#"{"data":{"args":["--config=/secure/path"],"builtin":false,"command":"example-plugin","env":["TOKEN=secret"],"name":"example-plugin","sha256":"d130b9a0fbfddef9709d8ff92e5e6053ccd246b78632fc03b8548457026961e9","version":"v1.0.0"}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
} else {
assert!(request.starts_with(
"DELETE /v1/sys/plugins/catalog/secret/example-plugin?version=v1.0.0 HTTP/1.1"
));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
}
}
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.sys()
.register_plugin(
openbao::sys::PluginType::Secret,
"example-plugin",
&openbao::sys::PluginRegisterRequest {
version: Some("v1.0.0".to_owned()),
sha256: "d130b9a0fbfddef9709d8ff92e5e6053ccd246b78632fc03b8548457026961e9"
.to_owned(),
command: "example-plugin".to_owned(),
args: vec![SecretString::from("--config=/secure/path")],
env: vec![SecretString::from("TOKEN=secret")],
oci: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
let info = client
.sys()
.read_plugin(
openbao::sys::PluginType::Secret,
"example-plugin",
Some("v1.0.0"),
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(info.name, "example-plugin");
assert_eq!(info.args[0].expose_secret(), "--config=/secure/path");
let debug = format!("{info:?}");
assert!(debug.contains("<1 redacted>"));
assert!(!debug.contains("TOKEN=secret"));
client
.sys()
.delete_plugin(
openbao::sys::PluginType::Secret,
"example-plugin",
Some("v1.0.0"),
)
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn sys_plugin_reload_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/sys/plugins/reload/backend HTTP/1.1"));
assert!(request.contains(r#""plugin":"example-plugin""#));
assert!(request.contains(r#""scope":"global""#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.sys()
.reload_plugin_backend(&openbao::sys::PluginReloadRequest {
plugin: Some("example-plugin".to_owned()),
mounts: Vec::new(),
scope: Some("global".to_owned()),
})
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn transit_create_key_sends_documented_path() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
assert!(request.starts_with("POST /v1/transit/keys/app-key HTTP/1.1"));
assert!(request.contains(r#""type":"aes256-gcm96""#));
assert!(request.contains(r#""derived":true"#));
let response = "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\n\r\n";
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
client
.transit("transit")
.unwrap_or_else(|error| panic!("{error}"))
.create_key(
"app-key",
&openbao::secrets::transit::TransitCreateKeyRequest {
key_type: Some(openbao::secrets::transit::TransitKeyType::Aes256Gcm96),
derived: Some(true),
..openbao::secrets::transit::TransitCreateKeyRequest::default()
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn transit_encrypt_and_decrypt_use_secret_payloads() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
for index in 0..2 {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
let body = if index == 0 {
assert!(request.starts_with("POST /v1/transit/encrypt/app-key HTTP/1.1"));
assert!(request.contains(r#""plaintext":"c2VjcmV0""#));
assert!(request.contains(r#""context":"YXBw""#));
r#"{"data":{"ciphertext":"vault:v1:ciphertext","key_version":1}}"#
} else {
assert!(request.starts_with("POST /v1/transit/decrypt/app-key HTTP/1.1"));
assert!(request.contains(r#""ciphertext":"vault:v1:ciphertext""#));
r#"{"data":{"plaintext":"c2VjcmV0"}}"#
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
}
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let transit = client
.transit("transit")
.unwrap_or_else(|error| panic!("{error}"));
let encrypted = transit
.encrypt(
"app-key",
&openbao::secrets::transit::TransitEncryptRequest {
plaintext: SecretString::from("c2VjcmV0"),
associated_data: None,
context: Some(SecretString::from("YXBw")),
key_version: None,
nonce: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(encrypted.ciphertext.expose_secret(), "vault:v1:ciphertext");
let decrypted = transit
.decrypt(
"app-key",
&openbao::secrets::transit::TransitDecryptRequest {
ciphertext: encrypted.ciphertext,
associated_data: None,
context: Some(SecretString::from("YXBw")),
nonce: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(decrypted.plaintext.expose_secret(), "c2VjcmV0");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn transit_crypto_helpers_use_documented_paths() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
for index in 0..4 {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
let body = match index {
0 => {
assert!(request.starts_with("POST /v1/transit/hash/sha2-512 HTTP/1.1"));
assert!(request.contains(r#""input":"cGF5bG9hZA==""#));
r#"{"data":{"sum":"abc123"}}"#
}
1 => {
assert!(request.starts_with("POST /v1/transit/hmac/app-key/sha2-512 HTTP/1.1"));
assert!(request.contains(r#""key_version":2"#));
r#"{"data":{"hmac":"vault:v1:hmac"}}"#
}
2 => {
assert!(
request.starts_with("POST /v1/transit/sign/signing-key/sha2-256 HTTP/1.1")
);
assert!(request.contains(r#""prehashed":true"#));
r#"{"data":{"signature":"vault:v1:signature"}}"#
}
_ => {
assert!(
request
.starts_with("POST /v1/transit/verify/signing-key/sha2-256 HTTP/1.1")
);
assert!(request.contains(r#""signature":"vault:v1:signature""#));
r#"{"data":{"valid":true}}"#
}
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
}
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let transit = client
.transit("transit")
.unwrap_or_else(|error| panic!("{error}"));
let hash = transit
.hash(
openbao::secrets::transit::TransitHashAlgorithm::Sha2_512,
&openbao::secrets::transit::TransitHashRequest {
input: SecretString::from("cGF5bG9hZA=="),
format: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(hash.sum.expose_secret(), "abc123");
let hmac = transit
.hmac(
"app-key",
Some(openbao::secrets::transit::TransitHashAlgorithm::Sha2_512),
&openbao::secrets::transit::TransitHmacRequest {
input: SecretString::from("cGF5bG9hZA=="),
key_version: Some(2),
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(hmac.hmac.expose_secret(), "vault:v1:hmac");
let signature = transit
.sign(
"signing-key",
Some(openbao::secrets::transit::TransitHashAlgorithm::Sha2_256),
&openbao::secrets::transit::TransitSignRequest {
input: SecretString::from("cGF5bG9hZA=="),
key_version: None,
context: None,
prehashed: Some(true),
signature_algorithm: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(signature.signature.expose_secret(), "vault:v1:signature");
let verified = transit
.verify(
"signing-key",
Some(openbao::secrets::transit::TransitHashAlgorithm::Sha2_256),
&openbao::secrets::transit::TransitVerifyRequest {
input: SecretString::from("cGF5bG9hZA=="),
signature: Some(signature.signature),
hmac: None,
context: None,
prehashed: None,
signature_algorithm: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert!(verified.valid);
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn transit_datakey_random_and_rewrap_use_documented_paths() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
for index in 0..3 {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
let body = match index {
0 => {
assert!(
request.starts_with("POST /v1/transit/datakey/wrapped/app-key HTTP/1.1")
);
assert!(request.contains(r#""bits":256"#));
r#"{"data":{"ciphertext":"vault:v1:datakey"}}"#
}
1 => {
assert!(request.starts_with("POST /v1/transit/random/platform/32 HTTP/1.1"));
assert!(request.contains(r#""format":"base64""#));
r#"{"data":{"random_bytes":"cmFuZG9t"}}"#
}
_ => {
assert!(request.starts_with("POST /v1/transit/rewrap/app-key HTTP/1.1"));
assert!(request.contains(r#""ciphertext":"vault:v1:old""#));
r#"{"data":{"ciphertext":"vault:v2:new"}}"#
}
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
}
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let transit = client
.transit("transit")
.unwrap_or_else(|error| panic!("{error}"));
let data_key = transit
.data_key(
"app-key",
openbao::secrets::transit::TransitDataKeyType::Wrapped,
&openbao::secrets::transit::TransitDataKeyRequest {
bits: Some(256),
..openbao::secrets::transit::TransitDataKeyRequest::default()
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(data_key.ciphertext.expose_secret(), "vault:v1:datakey");
let random = transit
.random_from_source(
openbao::secrets::transit::TransitRandomSource::Platform,
Some(32),
&openbao::secrets::transit::TransitRandomRequest {
format: Some(openbao::secrets::transit::TransitOutputFormat::Base64),
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(random.random_bytes.expose_secret(), "cmFuZG9t");
let rewrapped = transit
.rewrap(
"app-key",
&openbao::secrets::transit::TransitRewrapRequest {
ciphertext: SecretString::from("vault:v1:old"),
context: None,
key_version: None,
nonce: None,
},
)
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(rewrapped.ciphertext.expose_secret(), "vault:v2:new");
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn redirects_are_not_followed_with_token_headers() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let _bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let response = concat!(
"HTTP/1.1 302 Found\r\n",
"location: https://example.invalid/steal-token\r\n",
"content-length: 0\r\n",
"\r\n"
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let error = match client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read::<SecretData>("app/config")
.await
{
Ok(_) => panic!("redirect response unexpectedly succeeded"),
Err(error) => error,
};
match error {
Error::Api { status, .. } => assert_eq!(status, reqwest::StatusCode::FOUND),
unexpected => panic!("unexpected error: {unexpected}"),
}
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn response_content_length_is_bounded() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let _bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let response = concat!(
"HTTP/1.1 200 OK\r\n",
"content-type: application/json\r\n",
"content-length: 33554433\r\n",
"\r\n"
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let error = match client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read::<SecretData>("app/config")
.await
{
Ok(_) => panic!("oversized response unexpectedly succeeded"),
Err(error) => error,
};
assert!(matches!(error, Error::Decode(_)));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn non_json_content_type_is_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let _bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let body = r#"{"data":{"data":{"value":"ok"},"metadata":{"created_time":"2026-05-27T00:00:00Z","deletion_time":"","destroyed":false,"version":1}}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/html\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let error = match client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read::<SecretData>("app/config")
.await
{
Ok(_) => panic!("non-json content type unexpectedly succeeded"),
Err(error) => error,
};
assert!(matches!(error, Error::Decode(_)));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn missing_json_content_type_is_rejected() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 4096];
let _bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let body = r#"{"data":{"data":{"value":"ok"},"metadata":{"created_time":"2026-05-27T00:00:00Z","deletion_time":"","destroyed":false,"version":1}}}"#;
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config)
.unwrap_or_else(|error| panic!("{error}"))
.with_token(SecretString::from("test-token"));
let error = match client
.kv2("secret")
.unwrap_or_else(|error| panic!("{error}"))
.read::<SecretData>("app/config")
.await
{
Ok(_) => panic!("missing content type unexpectedly succeeded"),
Err(error) => error,
};
assert!(matches!(error, Error::Decode(_)));
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn dev_bootstrap_initializes_unseals_and_returns_root_client() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
let addr = listener
.local_addr()
.unwrap_or_else(|error| panic!("{error}"));
let server = thread::spawn(move || {
for step in 0..4 {
let (mut stream, _) = listener.accept().unwrap_or_else(|error| panic!("{error}"));
let mut buffer = [0_u8; 8192];
let bytes = stream
.read(&mut buffer)
.unwrap_or_else(|error| panic!("{error}"));
let request = String::from_utf8_lossy(&buffer[..bytes]);
let body = match step {
0 => {
assert!(request.starts_with("GET /v1/sys/init HTTP/1.1"));
r#"{"initialized":false}"#
}
1 => {
assert!(request.starts_with("POST /v1/sys/init HTTP/1.1"));
assert!(request.contains(r#""secret_shares":1"#));
assert!(request.contains(r#""secret_threshold":1"#));
r#"{"keys":["unseal-key"],"keys_base64":["dW5zZWFsLWtleQ=="],"root_token":"root-token"}"#
}
2 => {
assert!(request.starts_with("POST /v1/sys/unseal HTTP/1.1"));
assert!(request.contains(r#""key":"unseal-key""#));
r#"{"sealed":false,"n":1,"t":1,"progress":0,"version":"2.5.4"}"#
}
3 => {
assert!(request.starts_with("GET /v1/sys/health HTTP/1.1"));
assert!(request.contains("x-vault-token: root-token"));
r#"{"initialized":true,"sealed":false,"standby":false,"version":"2.5.4"}"#
}
_ => unreachable!(),
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.unwrap_or_else(|error| panic!("{error}"));
}
});
let config = OpenBaoConfig::new(format!("http://{addr}"))
.and_then(OpenBaoConfig::allow_localhost_http)
.unwrap_or_else(|error| panic!("{error}"));
let client = Client::from_config(config).unwrap_or_else(|error| panic!("{error}"));
let bootstrap = client
.sys()
.bootstrap_dev(&DevBootstrapOptions::default())
.await
.unwrap_or_else(|error| panic!("{error}"));
assert_eq!(bootstrap.root_token.expose_secret(), "root-token");
assert_eq!(bootstrap.unseal_keys.len(), 1);
assert!(!bootstrap.unseal_status.sealed);
let health = bootstrap
.client
.sys()
.health()
.await
.unwrap_or_else(|error| panic!("{error}"));
assert!(health.initialized);
assert!(!health.sealed);
server.join().unwrap_or_else(|error| panic!("{error:?}"));
}
#[tokio::test]
async fn dev_bootstrap_refuses_non_loopback_targets_before_http() {
let client = Client::new("https://example.com").unwrap_or_else(|error| panic!("{error}"));
let error = match client
.sys()
.bootstrap_dev(&DevBootstrapOptions::default())
.await
{
Ok(_) => panic!("non-loopback dev bootstrap unexpectedly succeeded"),
Err(error) => error,
};
assert!(matches!(error, Error::InvalidBaseUrl(_)));
}