use logbrew_cli::{CliEnvironment, execute_command, parse_command, write_runtime_error};
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn authenticated_read_logs_sends_bearer_token_and_prints_api_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(query_param("release", "checkout@1.2.3"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"logs": [
{
"level": "warning",
"severity": "warning",
"message": "checkout failed",
"release": "checkout@1.2.3",
"environment": "production",
"trace_id": "trace_123"
}
]
})))
.mount(&server)
.await;
let command = parse_command([
"logbrew",
"logs",
"--release",
"checkout@1.2.3",
"--environment",
"production",
"--json",
])
.expect("command parses");
let env = CliEnvironment {
base_url: server.uri(),
token: Some("test-token".to_owned()),
home: Some(std::env::temp_dir().join("logbrew-authenticated-read-test")),
cwd: None,
};
let mut output = Vec::new();
execute_command(&command, &env, &mut output)
.await
.expect("read succeeds");
let body: serde_json::Value = serde_json::from_slice(output.as_slice()).expect("valid json");
assert_eq!(body["logs"][0]["level"], "warning");
assert_eq!(body["logs"][0]["severity"], "warning");
assert_eq!(body["logs"][0]["message"], "checkout failed");
assert_eq!(body["logs"][0]["release"], "checkout@1.2.3");
assert_eq!(body["logs"][0]["environment"], "production");
}
#[tokio::test]
async fn human_read_logs_prints_scan_friendly_summary() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(query_param("release", "checkout@1.2.3"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"logs": [
{
"level": "warning",
"severity": "warning",
"message": "checkout failed",
"release": "checkout@1.2.3",
"environment": "production",
"trace_id": "trace_123"
}
]
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
[
"logbrew",
"logs",
"--release",
"checkout@1.2.3",
"--environment",
"production",
],
"human-read-logs",
)
.await
.expect("read succeeds");
assert_eq!(
text,
"Logs (1)\n- warning checkout failed trace=trace_123 [checkout@1.2.3 / production]\n"
);
}
#[tokio::test]
async fn human_read_logs_summarizes_level_only_array_shape_with_canonical_label() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(query_param("release", "checkout@1.2.3"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"level": "critical",
"severity": "critical",
"message": "checkout failed",
"release": "checkout@1.2.3",
"environment": "production",
"trace_id": "trace_123"
}
])))
.mount(&server)
.await;
let text = successful_human_output(
&server,
[
"logbrew",
"logs",
"--release",
"checkout@1.2.3",
"--env",
"production",
],
"human-read-logs-array",
)
.await
.expect("read succeeds");
assert_eq!(
text,
"Logs (1)\n- critical checkout failed trace=trace_123 [checkout@1.2.3 / production]\n"
);
}
#[tokio::test]
async fn human_empty_logs_prints_next_step() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(query_param("release", "empty@0"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"logs": []
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
[
"logbrew",
"logs",
"--release",
"empty@0",
"--environment",
"production",
],
"human-empty-logs",
)
.await
.expect("read succeeds");
assert_eq!(
text,
"Logs (0)\nNo logs found.\nNext: widen filters or check --release/--environment.\n"
);
}
#[tokio::test]
async fn human_empty_logs_summarizes_real_api_array_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(query_param("release", "empty@0"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
.mount(&server)
.await;
let text = successful_human_output(
&server,
[
"logbrew",
"logs",
"--release",
"empty@0",
"--environment",
"production",
],
"human-empty-logs-array",
)
.await
.expect("read succeeds");
assert_eq!(
text,
"Logs (0)\nNo logs found.\nNext: widen filters or check --release/--environment.\n"
);
}
#[tokio::test]
async fn human_read_actions_and_issues_summarize_pivot_context() {
struct ContextCase {
route: &'static str,
query: &'static [(&'static str, &'static str)],
body: serde_json::Value,
args: &'static [&'static str],
home: &'static str,
expected: &'static str,
}
let cases = [
ContextCase {
route: "/api/telemetry/actions",
query: &[
("release", "checkout@1.2.3"),
("environment", "production"),
("name", "checkout_failed"),
],
body: serde_json::json!([
{
"name": "checkout_failed",
"severity": "warning",
"distinct_id": "user_123",
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production"
}
]),
args: &[
"logbrew",
"actions",
"--release",
"checkout@1.2.3",
"--environment",
"production",
"--name",
"checkout_failed",
],
home: "human-read-actions-context",
expected: "Actions (1)\n- checkout_failed warning user=user_123 trace=trace_123 \
[checkout@1.2.3 / production]\n",
},
ContextCase {
route: "/api/telemetry/issues",
query: &[
("release", "checkout@1.2.3"),
("environment", "production"),
("status", "unresolved"),
],
body: serde_json::json!([
{
"id": "issue_123",
"status": "unresolved",
"severity": "error",
"title": "PaymentError",
"occurrence_count": 2,
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production"
}
]),
args: &[
"logbrew",
"issues",
"--release",
"checkout@1.2.3",
"--environment",
"production",
"--status",
"unresolved",
],
home: "human-read-issues-context",
expected: "Issues (1)\n- issue_123 unresolved error PaymentError occurrences=2 \
trace=trace_123 [checkout@1.2.3 / production]\n",
},
];
for case in cases {
let server = MockServer::start().await;
mount_authenticated_json(
&server,
"GET",
case.route,
case.query.iter().copied(),
case.body,
)
.await;
let text = successful_human_output(&server, case.args.iter().copied(), case.home)
.await
.expect("read succeeds");
assert_eq!(text, case.expected);
}
}
#[tokio::test]
async fn human_read_releases_prints_all_telemetry_counts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/telemetry/releases"))
.and(query_param("environment", "production"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"releases": [
{
"release": "checkout@1.2.3",
"environment": "production",
"log_count": 1,
"issue_count": 1,
"trace_span_count": 1,
"action_count": 1
}
]
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "releases", "--environment", "production"],
"human-read-releases",
)
.await
.expect("read succeeds");
assert_eq!(
text,
"Releases (1)\n- checkout@1.2.3 production logs=1 issues=1 spans=1 actions=1\n"
);
}
#[tokio::test]
async fn human_explain_trace_prints_scan_friendly_summary() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/telemetry/traces/trace_123"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"trace": {
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production",
"spans": [{"name": "checkout"}]
}
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "explain", "trace", "trace_123"],
"human-explain-trace",
)
.await
.expect("explain succeeds");
assert_eq!(
text,
"Trace trace_123 spans=1 [checkout@1.2.3 / production]\n- checkout\n"
);
}
#[tokio::test]
async fn human_read_trace_summarizes_real_api_array_shape() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/telemetry/traces/trace_123"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
{
"trace_id": "trace_123",
"name": "checkout",
"release": "checkout@1.2.3",
"environment": "production"
}
])))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "trace", "trace_123"],
"human-trace-array",
)
.await
.expect("trace succeeds");
assert_eq!(
text,
"Trace trace_123 spans=1 [checkout@1.2.3 / production]\n- checkout\n"
);
}
#[tokio::test]
async fn human_read_issue_summarizes_real_api_object_shape_with_next_action() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/telemetry/issues/issue_123"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "issue_123",
"status": "unresolved",
"severity": "error",
"title": "PaymentError",
"message": "card declined",
"occurrence_count": 2,
"first_seen_at": "2026-06-02T19:00:00Z",
"last_seen_at": "2026-06-02T20:00:00Z",
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production"
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "issue", "issue_123"],
"human-issue-object",
)
.await
.expect("issue succeeds");
assert_eq!(
text,
"Issue issue_123 unresolved error trace=trace_123 [checkout@1.2.3 / production]\nTitle: \
PaymentError\nMessage: card declined\nOccurrences: 2\nFirst seen: \
2026-06-02T19:00:00Z\nLast seen: 2026-06-02T20:00:00Z\nNext: logbrew resolve issue_123 \
or logbrew ignore issue_123\n"
);
}
#[tokio::test]
async fn human_set_issue_status_prints_confirmation() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/telemetry/issues/issue_123"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issue": {
"id": "issue_123",
"status": "resolved",
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production"
}
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "set", "issue", "issue_123", "resolved"],
"human-set-issue",
)
.await
.expect("set succeeds");
assert_eq!(
text,
"Issue issue_123 marked resolved trace=trace_123 [checkout@1.2.3 / production].\n"
);
}
#[tokio::test]
async fn human_set_issue_status_summarizes_real_api_object_shape() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/api/telemetry/issues/issue_123"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "issue_123",
"status": "resolved",
"trace_id": "trace_123",
"release": "checkout@1.2.3",
"environment": "production"
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "resolve", "issue_123"],
"human-set-issue-object",
)
.await
.expect("set succeeds");
assert_eq!(
text,
"Issue issue_123 marked resolved trace=trace_123 [checkout@1.2.3 / production].\n"
);
}
#[tokio::test]
async fn api_auth_error_reports_token_file_source_without_leaking_token()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer expired"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"ok": false,
"error": "not_logged_in"
})))
.mount(&server)
.await;
let command = parse_command(["logbrew", "logs", "--json"])?;
let home = api_rendering_home("api-auth-token-file")?;
let token_path = home.join(".logbrew").join("token");
std::fs::create_dir_all(token_path.parent().expect("token path has parent"))?;
std::fs::write(token_path.as_path(), "expired\n")?;
let env = CliEnvironment {
base_url: server.uri(),
token: None,
home: Some(home),
cwd: None,
};
let mut output = Vec::new();
let error = execute_command(&command, &env, &mut output)
.await
.expect_err("401 fails");
write_runtime_error(&error, command.wants_json(), &mut output)?;
let text = String::from_utf8(output)?;
assert!(!text.contains("expired"));
let body: serde_json::Value = serde_json::from_str(text.as_str())?;
assert_eq!(body["ok"], false);
assert_eq!(body["error"], "api_error");
assert_eq!(body["status"], 401);
assert_eq!(body["auth_source"], "token_file");
assert_eq!(body["next"], "run logbrew login");
Ok(())
}
#[tokio::test]
async fn human_api_auth_error_reports_env_source_without_leaking_token()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer env-token"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"ok": false,
"error": "forbidden"
})))
.mount(&server)
.await;
let command = parse_command(["logbrew", "logs"])?;
let home = api_rendering_home("api-auth-env")?;
let token_path = home.join(".logbrew").join("token");
std::fs::create_dir_all(token_path.parent().expect("token path has parent"))?;
std::fs::write(token_path.as_path(), "file-token\n")?;
let env = CliEnvironment {
base_url: server.uri(),
token: Some("env-token".to_owned()),
home: Some(home),
cwd: None,
};
let mut output = Vec::new();
let error = execute_command(&command, &env, &mut output)
.await
.expect_err("403 fails");
write_runtime_error(&error, command.wants_json(), &mut output)?;
let text = String::from_utf8(output)?;
assert!(text.contains("api returned status 403"));
assert!(text.contains("Auth: logged in (env token)"));
assert!(text.contains("Next: run logbrew login"));
assert!(!text.contains("env-token"));
assert!(!text.contains("file-token"));
Ok(())
}
async fn successful_human_output<I>(
server: &MockServer,
args: I,
home_name: &str,
) -> Result<String, Box<dyn std::error::Error>>
where
I: IntoIterator<Item = &'static str>,
{
let command = parse_command(args)?;
let env = authenticated_env(server, home_name);
let mut output = Vec::new();
execute_command(&command, &env, &mut output).await?;
Ok(String::from_utf8(output)?)
}
async fn mount_authenticated_json<I>(
server: &MockServer,
http_method: &str,
route: &str,
query: I,
body: serde_json::Value,
) where
I: IntoIterator<Item = (&'static str, &'static str)>,
{
let mut builder = Mock::given(method(http_method))
.and(path(route))
.and(header("authorization", "Bearer test-token"));
for (name, value) in query {
builder = builder.and(query_param(name, value));
}
builder
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
fn authenticated_env(server: &MockServer, home_name: &str) -> CliEnvironment {
CliEnvironment {
base_url: server.uri(),
token: Some("test-token".to_owned()),
home: Some(
std::env::temp_dir().join(format!("logbrew-{home_name}-{}", std::process::id())),
),
cwd: None,
}
}
fn api_rendering_home(name: &str) -> Result<std::path::PathBuf, std::io::Error> {
let dir = std::env::temp_dir().join(format!("logbrew-cli-{name}-{}", std::process::id()));
match std::fs::remove_dir_all(dir.as_path()) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
std::fs::create_dir_all(dir.as_path())?;
Ok(dir)
}