use logbrew_cli::{CliEnvironment, execute_command, parse_command, write_runtime_error};
use wiremock::matchers::{body_json, 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 collection_reads_preserve_json_shape_and_render_service_name() {
struct ServiceCase {
route: &'static str,
body: serde_json::Value,
args: &'static [&'static str],
home: &'static str,
expected: &'static str,
}
let cases = [
ServiceCase {
route: "/api/logs",
body: serde_json::json!([{
"level": "warning",
"message": "checkout failed",
"service_name": "checkout-api",
"trace_id": "trace_123"
}]),
args: &[
"logbrew",
"logs",
"--service",
"checkout-api",
"--since",
"24h",
],
home: "human-service-logs",
expected: "Logs (1)\n- warning checkout failed service=checkout-api trace=trace_123\n",
},
ServiceCase {
route: "/api/telemetry/issues",
body: serde_json::json!([{
"id": "issue_123",
"status": "unresolved",
"severity": "error",
"title": "PaymentError",
"occurrence_count": 2,
"service_name": "checkout-api",
"trace_id": "trace_123"
}]),
args: &[
"logbrew",
"issues",
"--service",
"checkout-api",
"--since",
"24h",
],
home: "human-service-issues",
expected: "Issues (1)\n- issue_123 unresolved error PaymentError occurrences=2 \
service=checkout-api trace=trace_123\n",
},
ServiceCase {
route: "/api/telemetry/actions",
body: serde_json::json!([{
"name": "checkout_failed",
"severity": "warning",
"service_name": "checkout-api",
"distinct_id": "user_123"
}]),
args: &[
"logbrew",
"actions",
"--service",
"checkout-api",
"--since",
"24h",
],
home: "human-service-actions",
expected: "Actions (1)\n- checkout_failed warning service=checkout-api user=user_123\n",
},
ServiceCase {
route: "/api/telemetry/releases",
body: serde_json::json!([{
"release": "checkout@1.2.3",
"environment": "production",
"service_name": "checkout-api",
"log_count": 1,
"issue_count": 1,
"trace_span_count": 1,
"action_count": 1
}]),
args: &[
"logbrew",
"releases",
"--service",
"checkout-api",
"--since",
"24h",
],
home: "human-service-releases",
expected: "Releases (1)\n- checkout@1.2.3 production service=checkout-api logs=1 issues=1 \
spans=1 actions=1\n",
},
];
for case in cases {
let server = MockServer::start().await;
mount_authenticated_json(
&server,
"GET",
case.route,
[("service_name", "checkout-api"), ("since", "24h")],
case.body,
)
.await;
let text = successful_human_output(&server, case.args.iter().copied(), case.home)
.await
.expect("service-scoped read succeeds");
assert_eq!(text, case.expected);
let mut json_args = case.args.to_vec();
json_args.push("--json");
let command = parse_command(json_args.iter().copied()).expect("JSON read parses");
let env = authenticated_env(&server, case.home);
let mut output = Vec::new();
execute_command(&command, &env, &mut output)
.await
.expect("JSON read succeeds");
let body: serde_json::Value =
serde_json::from_slice(output.as_slice()).expect("valid JSON response");
let rows = body.as_array().expect("backend list shape stays bare");
assert_eq!(rows[0]["service_name"], "checkout-api");
}
}
#[tokio::test]
async fn invalid_since_preserves_backend_validation_recovery_for_agents()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/telemetry/issues"))
.and(query_param("service_name", "checkout-api"))
.and(query_param("since", "0h"))
.and(header("authorization", "Bearer test-token"))
.respond_with(ResponseTemplate::new(422).set_body_json(serde_json::json!({
"code": "validation_failed",
"error": "invalid since value",
"next": "use since=24h, since=7d, or an RFC3339 timestamp"
})))
.mount(&server)
.await;
let command = parse_command([
"logbrew",
"issues",
"--service",
"checkout-api",
"--since",
"0h",
"--json",
])?;
let env = authenticated_env(&server, "invalid-since-recovery");
let mut output = Vec::new();
let error = execute_command(&command, &env, &mut output)
.await
.expect_err("invalid since fails");
write_runtime_error(&error, command.wants_json(), &mut output)?;
let body: serde_json::Value = serde_json::from_slice(output.as_slice())?;
assert_eq!(body["ok"], false);
assert_eq!(body["error"], "api_error");
assert_eq!(body["status"], 422);
assert_eq!(body["api_code"], "validation_failed");
assert_eq!(body["api_error"], "invalid since value");
assert_eq!(
body["next"],
"use since=24h, since=7d, or an RFC3339 timestamp"
);
Ok(())
}
#[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 project_setup_seen_posts_backend_owned_setup_state()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/projects/proj_123/setup/seen"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({
"runtime": "node",
"source": "cli",
"environment": "production"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "sdk_seen",
"runtime": "node",
"source": "cli",
"environment": "production",
"last_seen_at": "2026-06-15T20:00:00Z",
"next": "send telemetry for this project"
})))
.mount(&server)
.await;
let command = parse_command([
"logbrew",
"projects",
"setup",
"proj_123",
"--runtime",
"node",
"--source",
"cli",
"--environment",
"production",
"--json",
])?;
let env = authenticated_env(&server, "project-setup-seen-json");
let mut output = Vec::new();
execute_command(&command, &env, &mut output).await?;
let body: serde_json::Value = serde_json::from_slice(output.as_slice())?;
assert_eq!(body["status"], "sdk_seen");
assert_eq!(body["runtime"], "node");
assert_eq!(body["source"], "cli");
assert_eq!(body["environment"], "production");
assert_eq!(body["next"], "send telemetry for this project");
Ok(())
}
#[tokio::test]
async fn project_setup_seen_omits_source_for_ingest_key_auth()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/projects/proj_123/setup/seen"))
.and(header("authorization", "Bearer lbw_ingest_test"))
.and(body_json(serde_json::json!({ "runtime": "node" })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "sdk_seen",
"runtime": "node",
"source": "sdk",
"next": "send telemetry for this project"
})))
.mount(&server)
.await;
let command = parse_command([
"logbrew",
"projects",
"setup",
"proj_123",
"--runtime",
"node",
"--json",
])?;
let env = CliEnvironment {
base_url: server.uri(),
token: Some("lbw_ingest_test".to_owned()),
home: Some(std::env::temp_dir().join("logbrew-project-setup-ingest-key")),
cwd: None,
};
let mut output = Vec::new();
execute_command(&command, &env, &mut output).await?;
let body: serde_json::Value = serde_json::from_slice(output.as_slice())?;
assert_eq!(body["source"], "sdk");
Ok(())
}
#[tokio::test]
async fn human_project_setup_seen_prints_status_and_next() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/projects/proj_123/setup/seen"))
.and(header("authorization", "Bearer test-token"))
.and(body_json(serde_json::json!({ "source": "cli" })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "sdk_seen",
"source": "cli",
"last_seen_at": "2026-06-15T20:00:00Z",
"next": "send telemetry for this project"
})))
.mount(&server)
.await;
let text = successful_human_output(
&server,
["logbrew", "projects", "setup", "proj_123"],
"human-project-setup-seen",
)
.await
.expect("project setup seen succeeds");
assert_eq!(
text,
"Project setup seen: sdk_seen\nLast seen: 2026-06-15T20:00:00Z\nNext: send telemetry for this project\n"
);
}
#[tokio::test]
async fn local_auth_refreshes_once_and_persists_replacement_credentials()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer expired-access"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"error": "not_logged_in"
})))
.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/logs"))
.and(header("authorization", "Bearer fresh-access"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"logs": []
})))
.expect(1)
.mount(&server)
.await;
let command = parse_command(["logbrew", "logs", "--json"])?;
let home = api_rendering_home("refresh-local-auth")?;
let auth_dir = home.join(".logbrew");
std::fs::create_dir_all(auth_dir.as_path())?;
write_auth_session(
auth_dir.as_path(),
"expired-access",
"old-refresh",
server.uri().as_str(),
)?;
let env = CliEnvironment {
base_url: server.uri(),
token: None,
home: Some(home),
cwd: None,
};
let mut output = Vec::new();
execute_command(&command, &env, &mut output).await?;
let text = String::from_utf8(output)?;
let body: serde_json::Value = serde_json::from_str(text.as_str())?;
assert_eq!(body["logs"], serde_json::json!([]));
let saved = read_auth_session(auth_dir.as_path())?;
assert_eq!(saved["access_token"], "fresh-access");
assert_eq!(saved["refresh_token"], "fresh-refresh");
for secret in [
"expired-access",
"old-refresh",
"fresh-access",
"fresh-refresh",
] {
assert!(!text.contains(secret));
}
Ok(())
}
#[tokio::test]
async fn concurrent_local_401s_share_one_refresh_rotation() -> Result<(), Box<dyn std::error::Error>>
{
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer shared-expired"))
.respond_with(ResponseTemplate::new(401))
.expect(2)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.and(body_json(serde_json::json!({
"refresh_token": "shared-refresh"
})))
.respond_with(
ResponseTemplate::new(200)
.set_delay(std::time::Duration::from_millis(50))
.set_body_json(serde_json::json!({
"access_token": "shared-fresh",
"refresh_token": "shared-next-refresh"
})),
)
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer shared-fresh"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"logs": []
})))
.expect(2)
.mount(&server)
.await;
let home = api_rendering_home("refresh-concurrent")?;
let auth_dir = home.join(".logbrew");
std::fs::create_dir_all(auth_dir.as_path())?;
write_auth_session(
auth_dir.as_path(),
"shared-expired",
"shared-refresh",
server.uri().as_str(),
)?;
let env = CliEnvironment {
base_url: server.uri(),
token: None,
home: Some(home),
cwd: None,
};
let command = parse_command(["logbrew", "logs", "--json"])?;
let mut first_output = Vec::new();
let mut second_output = Vec::new();
let (first, second) = tokio::join!(
execute_command(&command, &env, &mut first_output),
execute_command(&command, &env, &mut second_output)
);
first?;
second?;
for output in [first_output, second_output] {
let text = String::from_utf8(output)?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&text)?["logs"],
serde_json::json!([])
);
for secret in [
"shared-expired",
"shared-refresh",
"shared-fresh",
"shared-next-refresh",
] {
assert!(!text.contains(secret));
}
}
Ok(())
}
#[tokio::test]
async fn env_auth_401_never_reads_or_rotates_local_refresh_credentials()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.and(header("authorization", "Bearer env-expired"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let home = api_rendering_home("refresh-env-isolated")?;
let auth_dir = home.join(".logbrew");
std::fs::create_dir_all(auth_dir.as_path())?;
std::fs::write(auth_dir.join("token"), "local-access\n")?;
std::fs::write(auth_dir.join("refresh-token"), "local-refresh\n")?;
let command = parse_command(["logbrew", "logs", "--json"])?;
let env = CliEnvironment {
base_url: server.uri(),
token: Some("env-expired".to_owned()),
home: Some(home),
cwd: None,
};
let mut output = Vec::new();
let error = execute_command(&command, &env, &mut output)
.await
.expect_err("env 401 remains an auth error");
assert!(matches!(
error,
logbrew_cli::RuntimeError::Api { status: 401, .. }
));
assert_eq!(
std::fs::read_to_string(auth_dir.join("token"))?.trim(),
"local-access"
);
assert_eq!(
std::fs::read_to_string(auth_dir.join("refresh-token"))?.trim(),
"local-refresh"
);
Ok(())
}
#[tokio::test]
async fn persisted_refresh_session_never_authenticates_to_a_different_api_origin()
-> Result<(), Box<dyn std::error::Error>> {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/logs"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/auth/refresh"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let home = api_rendering_home("refresh-origin-mismatch")?;
let auth_dir = home.join(".logbrew");
std::fs::create_dir_all(auth_dir.as_path())?;
write_auth_session(
auth_dir.as_path(),
"origin-access",
"origin-refresh",
"https://trusted.example",
)?;
let command = parse_command(["logbrew", "logs", "--json"])?;
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("origin mismatch fails before network access");
assert!(matches!(
error,
logbrew_cli::RuntimeError::Unavailable { .. }
));
assert!(output.is_empty());
Ok(())
}
#[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",
"detail": "env-token"
})))
.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)
}
fn write_auth_session(
auth_dir: &std::path::Path,
access_token: &str,
refresh_token: &str,
origin: &str,
) -> Result<(), std::io::Error> {
std::fs::write(
auth_dir.join("session.json"),
serde_json::json!({
"access_token": access_token,
"refresh_token": refresh_token,
"origin": origin,
})
.to_string(),
)
}
fn read_auth_session(
auth_dir: &std::path::Path,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
Ok(serde_json::from_str(
std::fs::read_to_string(auth_dir.join("session.json"))?.as_str(),
)?)
}