use logbrew_cli::{
CliEnvironment, Command, HttpMethod, RuntimeError, execute_command, parse_command,
write_cli_error, write_runtime_error,
};
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const PROJECT_ID: &str = "123e4567-e89b-12d3-a456-426614174000";
#[test]
fn project_aliases_and_json_order_parse_as_authenticated_reads() {
for (args, json) in [
(&["logbrew", "projects"][..], false),
(&["logbrew", "project"][..], false),
(&["logbrew", "projects", "--json"][..], true),
(&["logbrew", "project", "--json"][..], true),
(&["logbrew", "--json", "projects"][..], true),
(&["logbrew", "--json", "project"][..], true),
] {
let command = parse_command(args.iter().copied()).expect("project catalog parses");
assert!(
!matches!(command, Command::Help { .. }),
"project catalog must execute instead of returning help"
);
assert_eq!(command.http_path().as_deref(), Some("/api/projects"));
assert_eq!(command.http_method(), Some(HttpMethod::Get));
assert_eq!(command.wants_json(), json);
assert!(command.request_body().is_none());
}
}
#[test]
fn project_catalog_grammar_failures_are_fixed_and_value_safe() {
for args in [
vec![
String::from("logbrew"),
String::from("projects"),
String::from("--authorization=hostile-secret\ncontrol"),
String::from("--json"),
],
vec![
String::from("logbrew"),
String::from("project"),
String::from("hostile-private-value"),
String::from("--json"),
],
vec![
String::from("logbrew"),
String::from("projects"),
String::from("--json"),
String::from("--json"),
],
] {
let error = parse_command(args).expect_err("project catalog grammar fails closed");
let mut output = Vec::new();
write_cli_error(&error, true, &mut output).expect("parse error writes");
let text = String::from_utf8(output).expect("utf8 parse error");
let body: serde_json::Value = serde_json::from_str(text.as_str()).expect("valid json");
assert_eq!(body["error"], "invalid_projects_command");
assert_eq!(body["message"], "invalid projects command");
assert_eq!(
body["next"],
"use logbrew projects with optional --json, or logbrew projects --help"
);
assert!(!text.contains("hostile-secret"));
assert!(!text.contains("hostile-private-value"));
assert!(!text.contains("authorization"));
}
}
#[tokio::test]
async fn projects_json_preserves_exact_bare_array_and_sends_no_query()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
let response = serde_json::to_string_pretty(&project_catalog())?;
Mock::given(method("GET"))
.and(path("/api/projects"))
.and(header("authorization", "Bearer account-token"))
.respond_with(ResponseTemplate::new(200).set_body_raw(response.clone(), "application/json"))
.expect(2)
.mount(&server)
.await;
for alias in ["projects", "project"] {
let command = parse_command(["logbrew", alias, "--json"])?;
let mut output = Vec::new();
execute_command(&command, &environment(&server), &mut output).await?;
assert_eq!(String::from_utf8(output)?, format!("{response}\n"));
}
let requests = server
.received_requests()
.await
.ok_or("requests disabled")?;
assert!(
requests
.iter()
.all(|request| request.url.query().is_none() && request.body.is_empty())
);
Ok(())
}
#[tokio::test]
async fn projects_human_output_is_bounded_and_scan_oriented()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(project_catalog()))
.mount(&server)
.await;
let command = parse_command(["logbrew", "projects"])?;
let mut output = Vec::new();
execute_command(&command, &environment(&server), &mut output).await?;
assert_eq!(
String::from_utf8(output)?,
"Projects (1)\n\
- Example Project id=123e4567-e89b-12d3-a456-426614174000 setup=sdk_seen \
last_seen=2026-07-25T08:30:00Z\n"
);
Ok(())
}
#[tokio::test]
async fn projects_rejects_envelopes_partial_rows_and_hostile_text()
-> Result<(), Box<dyn std::error::Error>> {
let mut partial = project_catalog();
drop(
partial[0]
.as_object_mut()
.ok_or("project fixture must be an object")?
.remove("created_at"),
);
let mut extra = project_catalog();
drop(
extra[0]
.as_object_mut()
.ok_or("project fixture must be an object")?
.insert(
String::from("private_token"),
serde_json::json!("hostile-secret"),
),
);
let cases = [
serde_json::json!({"projects": project_catalog()}),
partial,
extra,
serde_json::json!([{
"id": PROJECT_ID,
"name": "hostile\ncontrol",
"provider_project_id": "provider-project",
"provider_project_slug": null,
"provider": "logbrew",
"is_active": true,
"language": null,
"setup_status": "created",
"setup_started_at": null,
"first_telemetry_seen_at": null,
"last_seen_at": null,
"last_release": null,
"last_environment": null,
"created_at": "2026-07-25T08:00:00Z"
}]),
];
for (index, response) in cases.into_iter().enumerate() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/projects"))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.mount(&server)
.await;
let command = parse_command(["logbrew", "projects", "--json"])?;
let error = execute_command(&command, &environment(&server), &mut Vec::new())
.await
.expect_err("malformed project catalog fails closed");
let mut output = Vec::new();
write_runtime_error(&error, true, &mut output)?;
let text = String::from_utf8(output)?;
assert!(
matches!(error, RuntimeError::Unavailable { .. }),
"unexpected case {index} error: {error:?}"
);
assert!(text.contains("project catalog response was invalid"));
assert!(!text.contains("hostile-secret"));
assert!(!text.contains("hostile\\ncontrol"));
assert!(!text.contains(server.uri().as_str()));
}
Ok(())
}
#[tokio::test]
async fn projects_refreshes_local_account_auth_once() -> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/projects"))
.and(header("authorization", "Bearer expired-access"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "Invalid or expired token",
"code": "unauthorized",
"next": "sign in again",
"next_action": {"code": "sign_in", "target": "auth"}
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.and(body_json(
serde_json::json!({"refresh_token": "old-refresh"}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "fresh-access",
"refresh_token": "fresh-refresh"
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/projects"))
.and(header("authorization", "Bearer fresh-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.expect(1)
.mount(&server)
.await;
let home = temporary_home("refresh")?;
write_session(
home.as_path(),
"expired-access",
"old-refresh",
server.uri().as_str(),
)?;
let command = parse_command(["logbrew", "projects", "--json"])?;
let env = CliEnvironment {
base_url: server.uri(),
token: None,
home: Some(home.clone()),
cwd: None,
};
let mut output = Vec::new();
execute_command(&command, &env, &mut output).await?;
assert_eq!(String::from_utf8(output)?, "[]\n");
let session: serde_json::Value = serde_json::from_str(
std::fs::read_to_string(home.join(".logbrew/session.json"))?.as_str(),
)?;
assert_eq!(session["access_token"], "fresh-access");
assert_eq!(session["refresh_token"], "fresh-refresh");
Ok(())
}
#[tokio::test]
async fn project_errors_use_only_typed_local_recovery() -> Result<(), Box<dyn std::error::Error>> {
for (status, code, action_code, action_target, expected_code) in [
(401, "unauthorized", "sign_in", "auth", "unauthorized"),
(
405,
"method_not_allowed",
"use_supported_method",
"api_method",
"method_not_allowed",
),
(500, "storage_error", "retry", "request", "server_error"),
] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/projects"))
.respond_with(
ResponseTemplate::new(status).set_body_json(serde_json::json!({
"error": "hostile private token",
"code": code,
"next": "send credentials to a private host",
"next_action": {"code": action_code, "target": action_target}
})),
)
.mount(&server)
.await;
let command = parse_command(["logbrew", "projects", "--json"])?;
let error = execute_command(&command, &environment(&server), &mut Vec::new())
.await
.expect_err("typed project error remains non-success");
let mut output = Vec::new();
write_runtime_error(&error, true, &mut output)?;
let text = String::from_utf8(output)?;
let body: serde_json::Value = serde_json::from_str(text.as_str())?;
assert_eq!(body["api_code"], expected_code);
assert!(!text.contains("hostile"));
assert!(!text.contains("credentials"));
assert!(!text.contains(server.uri().as_str()));
}
Ok(())
}
fn environment(server: &MockServer) -> CliEnvironment {
CliEnvironment {
base_url: server.uri(),
token: Some(String::from("account-token")),
home: None,
cwd: None,
}
}
fn project_catalog() -> serde_json::Value {
serde_json::json!([{
"id": PROJECT_ID,
"name": "Example Project",
"provider_project_id": "provider-project",
"provider_project_slug": null,
"provider": "logbrew",
"is_active": true,
"language": "swift",
"setup_status": "sdk_seen",
"setup_started_at": "2026-07-25T08:00:00Z",
"first_telemetry_seen_at": null,
"last_seen_at": "2026-07-25T08:30:00Z",
"last_release": "1.0.0",
"last_environment": "production",
"created_at": "2026-07-25T07:00:00Z"
}])
}
fn temporary_home(label: &str) -> Result<std::path::PathBuf, std::io::Error> {
let path =
std::env::temp_dir().join(format!("logbrew-projects-{label}-{}", std::process::id()));
match std::fs::remove_dir_all(path.as_path()) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
std::fs::create_dir_all(path.as_path())?;
Ok(path)
}
fn write_session(
home: &std::path::Path,
access_token: &str,
refresh_token: &str,
origin: &str,
) -> Result<(), std::io::Error> {
let auth_dir = home.join(".logbrew");
std::fs::create_dir_all(auth_dir.as_path())?;
std::fs::write(
auth_dir.join("session.json"),
serde_json::json!({
"access_token": access_token,
"refresh_token": refresh_token,
"origin": origin,
})
.to_string(),
)
}