#![allow(clippy::result_large_err)]
use super::config::{GcpConfig, GcpToken};
use super::error::GcpError;
#[derive(Debug, PartialEq, Eq)]
pub enum PushOutcome {
Created,
Updated,
Unchanged,
Deleted,
}
const MAX_RETRIES_429: u32 = 3;
const MAX_RETRIES_TRANSIENT: u32 = 5;
const DEFAULT_RETRY_SECS: u64 = 2;
fn http_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
}
fn call_with_retry(
make_request: impl Fn() -> Result<ureq::Response, ureq::Error>,
) -> Result<ureq::Response, ureq::Error> {
let mut throttled_attempt = 0u32;
let mut transient_attempt = 0u32;
loop {
match make_request() {
Ok(resp) => return Ok(resp),
Err(ureq::Error::Status(429, resp)) if throttled_attempt < MAX_RETRIES_429 => {
let retry_after = resp
.header("Retry-After")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_RETRY_SECS * 2u64.pow(throttled_attempt));
let wait = std::cmp::min(jittered_delay_secs(retry_after), 30);
std::thread::sleep(std::time::Duration::from_secs(wait));
throttled_attempt += 1;
}
Err(ureq::Error::Transport(t))
if transient_attempt < MAX_RETRIES_TRANSIENT
&& is_retryable_transport_error(t.to_string().as_str()) =>
{
let backoff = DEFAULT_RETRY_SECS * 2u64.pow(transient_attempt);
let wait = std::cmp::min(jittered_delay_secs(backoff), 30);
std::thread::sleep(std::time::Duration::from_secs(wait));
transient_attempt += 1;
}
Err(e) => return Err(e),
}
}
}
fn is_retryable_transport_error(message: &str) -> bool {
let msg = message.to_ascii_lowercase();
msg.contains("timed out")
|| msg.contains("timeout")
|| msg.contains("connection reset")
|| msg.contains("connection refused")
|| msg.contains("econnreset")
|| msg.contains("econnrefused")
|| msg.contains("temporar")
}
fn jittered_delay_secs(base_secs: u64) -> u64 {
if base_secs == 0 {
return 0;
}
let jitter_cap = std::cmp::max(1, base_secs / 4);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
base_secs + (nanos % (jitter_cap + 1))
}
fn map_ureq_error(e: ureq::Error, secret_name: Option<&str>) -> GcpError {
match e {
ureq::Error::Status(404, _) => GcpError::NotFound(secret_name.unwrap_or("").to_string()),
ureq::Error::Status(s, resp) => GcpError::Http {
status: s,
message: resp
.into_string()
.unwrap_or_else(|_| "<unreadable response>".into()),
},
other => GcpError::Transport(other.to_string()),
}
}
pub fn normalize_name(name: &str) -> String {
name.replace(['-', '.'], "_").to_uppercase()
}
fn extract_short_name(full_name: &str) -> &str {
full_name.split('/').next_back().unwrap_or(full_name)
}
pub fn pull_secrets(
cfg: &GcpConfig,
get_token: &impl Fn() -> Result<GcpToken, GcpError>,
prefix: Option<&str>,
) -> Result<Vec<(String, String)>, GcpError> {
let names = list_secret_names(cfg, get_token, prefix)?;
let token = get_token()?;
let mut secrets = Vec::new();
for name in &names {
let value = access_secret(cfg, &token, name)?;
let key = normalize_name(extract_short_name(name));
secrets.push((key, value));
}
Ok(secrets)
}
fn list_secret_names(
cfg: &GcpConfig,
get_token: &impl Fn() -> Result<GcpToken, GcpError>,
prefix: Option<&str>,
) -> Result<Vec<String>, GcpError> {
let mut names = Vec::new();
let mut page_token: Option<String> = None;
let agent = http_agent();
loop {
let token = get_token()?;
let auth = format!("Bearer {}", token.0);
let mut url = format!(
"{}/projects/{}/secrets?pageSize=100",
cfg.endpoint, cfg.project_id
);
if let Some(ref pt) = page_token {
url.push_str("&pageToken=");
url.push_str(&percent_encode_query_value(pt));
}
let url_clone = url.clone();
let resp: serde_json::Value =
call_with_retry(|| agent.get(&url_clone).set("Authorization", &auth).call())
.map_err(|e| map_ureq_error(e, None))?
.into_json()
.map_err(|e| GcpError::Transport(e.to_string()))?;
if let Some(secrets) = resp["secrets"].as_array() {
for item in secrets {
if let Some(full_name) = item["name"].as_str() {
let short = extract_short_name(full_name);
if let Some(p) = prefix {
if !short.to_lowercase().starts_with(&p.to_lowercase()) {
continue;
}
}
if !short.is_empty() {
names.push(full_name.to_string());
}
}
}
}
page_token = resp["nextPageToken"].as_str().map(|s| s.to_string());
if page_token.is_none() {
break;
}
}
Ok(names)
}
fn percent_encode_query_value(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
fn access_secret(cfg: &GcpConfig, token: &GcpToken, name: &str) -> Result<String, GcpError> {
let short = extract_short_name(name);
let url = format!("{}/{name}/versions/latest:access", cfg.endpoint);
let auth = format!("Bearer {}", token.0);
let url_clone = url.clone();
let agent = http_agent();
let resp: serde_json::Value =
call_with_retry(|| agent.get(&url_clone).set("Authorization", &auth).call())
.map_err(|e| map_ureq_error(e, Some(short)))?
.into_json()
.map_err(|e| GcpError::Transport(e.to_string()))?;
let b64 = resp["payload"]["data"]
.as_str()
.ok_or_else(|| GcpError::NotFound(short.to_string()))?;
decode_secret_data(b64, short)
}
fn decode_secret_data(b64: &str, name: &str) -> Result<String, GcpError> {
let bytes = base64_decode(b64)
.map_err(|e| GcpError::Transport(format!("base64 decode failed for '{name}': {e}")))?;
String::from_utf8(bytes).map_err(|_| {
GcpError::NotFound(format!(
"{name} — payload is not valid UTF-8 (binary secret not supported)"
))
})
}
#[tracing::instrument(skip(cfg, get_token, value), fields(name = %name))]
pub fn push_secret(
cfg: &GcpConfig,
get_token: &impl Fn() -> Result<GcpToken, GcpError>,
name: &str,
value: &str,
) -> Result<PushOutcome, GcpError> {
let token = get_token()?;
let agent = http_agent();
let auth = format!("Bearer {}", token.0);
let exists = check_secret_exists(cfg, &agent, &auth, name)?;
if !exists {
create_secret_resource(cfg, &agent, &auth, name)?;
}
let add_result = add_secret_version(cfg, &agent, &auth, name, value);
if let Err(ref e) = add_result {
if !exists {
tracing::warn!(
secret_name = %name,
error = %e,
"partial-failure: secret resource created but version-add failed; \
secret has no versions — re-run gcp-push to recover"
);
}
return add_result.map(|_| PushOutcome::Created); }
if exists {
Ok(PushOutcome::Updated)
} else {
Ok(PushOutcome::Created)
}
}
fn check_secret_exists(
cfg: &GcpConfig,
agent: &ureq::Agent,
auth: &str,
name: &str,
) -> Result<bool, GcpError> {
let url = format!(
"{}/projects/{}/secrets/{}",
cfg.endpoint, cfg.project_id, name
);
let url_clone = url.clone();
match call_with_retry(|| agent.get(&url_clone).set("Authorization", auth).call()) {
Ok(_) => Ok(true),
Err(ureq::Error::Status(404, _)) => Ok(false),
Err(e) => Err(map_ureq_error(e, Some(name))),
}
}
fn create_secret_resource(
cfg: &GcpConfig,
agent: &ureq::Agent,
auth: &str,
name: &str,
) -> Result<(), GcpError> {
let url = format!(
"{}/projects/{}/secrets?secretId={}",
cfg.endpoint, cfg.project_id, name
);
let body = serde_json::json!({
"replication": { "automatic": {} }
});
let url_clone = url.clone();
let body_str = body.to_string();
call_with_retry(|| {
agent
.post(&url_clone)
.set("Authorization", auth)
.set("Content-Type", "application/json")
.send_string(&body_str)
})
.map_err(|e| map_ureq_error(e, Some(name)))?;
Ok(())
}
fn add_secret_version(
cfg: &GcpConfig,
agent: &ureq::Agent,
auth: &str,
name: &str,
value: &str,
) -> Result<(), GcpError> {
let url = format!(
"{}/projects/{}/secrets/{}/versions:add",
cfg.endpoint, cfg.project_id, name
);
let b64_value = base64_encode(value.as_bytes());
let body = serde_json::json!({
"payload": { "data": b64_value }
});
let url_clone = url.clone();
let body_str = body.to_string();
call_with_retry(|| {
agent
.post(&url_clone)
.set("Authorization", auth)
.set("Content-Type", "application/json")
.send_string(&body_str)
})
.map_err(|e| map_ureq_error(e, Some(name)))?;
Ok(())
}
pub(crate) fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
let mut i = 0;
while i < data.len() {
let b0 = data[i] as u32;
let b1 = if i + 1 < data.len() {
data[i + 1] as u32
} else {
0
};
let b2 = if i + 2 < data.len() {
data[i + 2] as u32
} else {
0
};
out.push(CHARS[((b0 >> 2) & 0x3F) as usize] as char);
out.push(CHARS[(((b0 << 4) | (b1 >> 4)) & 0x3F) as usize] as char);
out.push(if i + 1 < data.len() {
CHARS[(((b1 << 2) | (b2 >> 6)) & 0x3F) as usize] as char
} else {
'='
});
out.push(if i + 2 < data.len() {
CHARS[(b2 & 0x3F) as usize] as char
} else {
'='
});
i += 3;
}
out
}
fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
let s = s.trim_end_matches('=');
if s.len() % 4 == 1 {
return Err("invalid base64 length".into());
}
let mut out = Vec::with_capacity(s.len() * 3 / 4 + 1);
let mut buf = 0u32;
let mut bits = 0u32;
for ch in s.bytes() {
let v = match ch {
b'A'..=b'Z' => (ch - b'A') as u32,
b'a'..=b'z' => (ch - b'a' + 26) as u32,
b'0'..=b'9' => (ch - b'0' + 52) as u32,
b'+' => 62,
b'/' => 63,
_ => return Err(format!("invalid base64 char: {}", ch as char)),
};
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((buf >> bits) & 0xFF) as u8);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::super::config::GcpConfig;
use super::*;
fn cfg(url: &str) -> GcpConfig {
GcpConfig::with_endpoint("test-project", format!("{url}/v1"))
}
fn test_token() -> GcpToken {
GcpToken("test-token".into())
}
fn list_response(names: &[&str], next_token: Option<&str>) -> String {
let items: Vec<String> = names
.iter()
.map(|n| format!(r#"{{"name":"projects/test-project/secrets/{n}"}}"#))
.collect();
match next_token {
Some(tok) => format!(
r#"{{"secrets":[{}],"nextPageToken":"{tok}"}}"#,
items.join(",")
),
None => format!(r#"{{"secrets":[{}]}}"#, items.join(",")),
}
}
fn access_response(value: &str) -> String {
let b64 = base64_encode(value.as_bytes());
format!(
r#"{{"name":"projects/test-project/secrets/my-secret/versions/1","payload":{{"data":"{b64}"}}}}"#
)
}
#[test]
fn normalize_name_hyphens() {
assert_eq!(normalize_name("my-secret"), "MY_SECRET");
}
#[test]
fn normalize_name_dots() {
assert_eq!(normalize_name("db.password"), "DB_PASSWORD");
}
#[test]
fn normalize_name_underscores_preserved() {
assert_eq!(normalize_name("api_key"), "API_KEY");
}
#[test]
fn percent_encode_query_value_escapes_reserved_bytes() {
assert_eq!(
percent_encode_query_value("tok/with+=reserved"),
"tok%2Fwith%2B%3Dreserved"
);
}
#[test]
fn extract_short_name_from_full_path() {
assert_eq!(
extract_short_name("projects/my-project/secrets/my-secret"),
"my-secret"
);
}
#[test]
fn extract_short_name_passthrough_for_plain_name() {
assert_eq!(extract_short_name("my-secret"), "my-secret");
}
#[test]
fn base64_decode_hello() {
let encoded = "aGVsbG8="; let bytes = base64_decode(encoded).unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), "hello");
}
#[test]
fn base64_decode_no_padding() {
let bytes = base64_decode("aGVsbG8").unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), "hello");
}
#[test]
fn base64_decode_empty() {
assert!(base64_decode("").unwrap().is_empty());
}
#[test]
fn base64_decode_rejects_impossible_length() {
let err = base64_decode("a").unwrap_err();
assert!(err.contains("invalid base64 length"));
}
#[test]
fn base64_roundtrip() {
let original = "s3cr3t-v@lue!";
let encoded = base64_encode(original.as_bytes());
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(String::from_utf8(decoded).unwrap(), original);
}
#[test]
fn pull_secrets_empty_vault() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"secrets":[]}"#)
.create();
let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap();
assert!(result.is_empty());
}
#[test]
fn pull_secrets_fetches_and_normalises_key() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["my-db-password"], None))
.create();
let _access = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/v1/projects/test-project/secrets/my-db-password/versions/latest:access"
.to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(access_response("s3cr3t"))
.create();
let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].0, "MY_DB_PASSWORD");
assert_eq!(secrets[0].1, "s3cr3t");
}
#[test]
fn pull_secrets_pagination() {
let mut server = mockito::Server::new();
let _page1 = server
.mock(
"GET",
mockito::Matcher::Regex(
r"^/v1/projects/test-project/secrets\?pageSize".to_string(),
),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["secret-a"], Some("page2-tok")))
.expect(1)
.create();
let _page2 = server
.mock(
"GET",
mockito::Matcher::Regex(r"pageToken=page2-tok".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["secret-b"], None))
.expect(1)
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(access_response("val"))
.expect(2)
.create();
let secrets = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap();
assert_eq!(secrets.len(), 2);
let keys: Vec<&str> = secrets.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"SECRET_A"));
assert!(keys.contains(&"SECRET_B"));
}
#[test]
fn pull_secrets_prefix_filter() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["app-token", "db-password"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"app-token/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(access_response("tok-xyz"))
.create();
let secrets =
pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), Some("app-")).unwrap();
assert_eq!(secrets.len(), 1);
assert_eq!(secrets[0].0, "APP_TOKEN");
}
#[test]
fn pull_secrets_prefix_filter_is_case_insensitive() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["App-Token", "db-password"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"App-Token/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(access_response("tok-xyz"))
.expect(1)
.create();
let secrets =
pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), Some("app-")).unwrap();
assert_eq!(
secrets,
vec![("APP_TOKEN".to_string(), "tok-xyz".to_string())]
);
}
#[test]
fn pull_secrets_404_returns_not_found() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["ghost"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"ghost/versions/latest:access".to_string()),
)
.with_status(404)
.with_body(
r#"{"error":{"code":404,"message":"Secret not found","status":"NOT_FOUND"}}"#,
)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(
matches!(err, GcpError::NotFound(_)),
"expected NotFound, got {err:?}"
);
}
#[test]
fn pull_secrets_403_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets".to_string()),
)
.with_status(403)
.with_body(r#"{"error":{"code":403,"message":"Permission denied","status":"PERMISSION_DENIED"}}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(
matches!(err, GcpError::Http { status: 403, .. }),
"expected Http 403, got {err:?}"
);
}
#[test]
fn pull_secrets_503_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets".to_string()),
)
.with_status(503)
.with_body("Service Unavailable")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(matches!(err, GcpError::Http { status: 503, .. }));
}
#[test]
fn pull_secrets_malformed_list_json_returns_transport_error() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body("not json {{{{")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(matches!(err, GcpError::Transport(_)));
}
#[test]
fn pull_secrets_malformed_access_json_returns_transport_error() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["my-secret"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"my-secret/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body("not json")
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(matches!(err, GcpError::Transport(_)));
}
#[test]
fn access_invalid_base64_payload_returns_transport_error() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["bad-b64"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"bad-b64/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"payload":{"data":"a"}}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(
matches!(err, GcpError::Transport(ref msg) if msg.contains("base64 decode failed")),
"expected base64 transport error, got {err:?}"
);
}
#[test]
fn access_binary_payload_returns_not_found() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["binary-secret"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"binary-secret/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"payload":{"data":"//8="}}"#)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(
matches!(err, GcpError::NotFound(ref msg) if msg.contains("payload is not valid UTF-8")),
"expected non-UTF-8 payload error, got {err:?}"
);
}
#[test]
fn pull_secrets_429_exhausts_retries_returns_http_error() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets".to_string()),
)
.with_status(429)
.with_header("Retry-After", "0")
.with_body("Too Many Requests")
.expect(MAX_RETRIES_429 as usize + 1)
.create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(matches!(err, GcpError::Http { status: 429, .. }));
}
#[test]
fn token_refresh_failure_before_access_phase_propagates_error() {
use std::sync::atomic::{AtomicUsize, Ordering};
let call_count = AtomicUsize::new(0);
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["my-secret"], None))
.create();
let err = pull_secrets(
&cfg(&server.url()),
&|| {
let n = call_count.fetch_add(1, Ordering::SeqCst);
if n == 0 {
Ok(test_token())
} else {
Err(GcpError::Auth("token expired".into()))
}
},
None,
)
.unwrap_err();
assert!(
matches!(err, GcpError::Auth(_)),
"expected Auth error on token refresh, got {err:?}"
);
}
#[test]
fn token_failure_on_first_list_call_propagates_error() {
let server = mockito::Server::new();
let err = pull_secrets(
&cfg(&server.url()),
&|| Err(GcpError::Auth("no credentials".into())),
None,
)
.unwrap_err();
assert!(matches!(err, GcpError::Auth(_)));
}
#[test]
fn access_missing_payload_returns_not_found() {
let mut server = mockito::Server::new();
let _list = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(list_response(&["partial-secret"], None))
.create();
let _acc = server
.mock(
"GET",
mockito::Matcher::Regex(r"partial-secret/versions/latest:access".to_string()),
)
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"name":"...","payload":{}}"#) .create();
let err = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap_err();
assert!(
matches!(err, GcpError::NotFound(_)),
"missing payload.data should return NotFound, got {err:?}"
);
}
#[test]
fn authorization_header_contains_bearer_token() {
let mut server = mockito::Server::new();
let _m = server
.mock(
"GET",
mockito::Matcher::Regex(r"^/v1/projects/test-project/secrets\?".to_string()),
)
.match_header("Authorization", "Bearer test-token")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"secrets":[]}"#)
.create();
let result = pull_secrets(&cfg(&server.url()), &|| Ok(test_token()), None).unwrap();
assert!(result.is_empty());
}
#[test]
fn retryable_transport_classifier_detects_timeout() {
assert!(is_retryable_transport_error("operation timed out"));
assert!(is_retryable_transport_error("Connection refused"));
assert!(!is_retryable_transport_error("permission denied"));
}
#[test]
fn jittered_delay_stays_within_25_percent_bound() {
let base = 20;
let jittered = jittered_delay_secs(base);
assert!(jittered >= base);
assert!(jittered <= base + (base / 4));
}
}